diff --git a/.gitignore b/.gitignore index 8ea0f0c0..24a63f09 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,7 @@ app.*.map.json docs/superpowers/plans/2026-07-26-aircon-meter-logger.md # Widget Preview related .widget_preview/ + +# Local research and audit notes +/docs/apple_watch_code_audit.md +/docs/dlut_* diff --git a/docs/README.md b/docs/README.md index 7d99ade0..29edee4b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -43,3 +43,5 @@ XDYou,代码称为 Traintime PDA,是为西电学生设计的开源信息查 - [常见问题问答](faq.md) - [技术终极回顾](https://legacy.superbart.top/writing/Traintime%20PDA%20Ultimate%20Review.html) - [涉及到的数据结构](data_structure.md) + - [Apple Watch 功能与源码入口](../watchOS/README.md) + - [Apple Watch 架构、同步与维护](../watchOS/apple_watch_technical_overview.md) diff --git a/ios/ClasstableWidget/ClasstableWidget.swift b/ios/ClasstableWidget/ClasstableWidget.swift index 922c5553..9c17e065 100644 --- a/ios/ClasstableWidget/ClasstableWidget.swift +++ b/ios/ClasstableWidget/ClasstableWidget.swift @@ -419,51 +419,38 @@ struct Provider: TimelineProvider { // Order arrangement.sort(by: {$0.start_time < $1.start_time}) logger.info("Successfully fetcn arrangement data, it have \(arrangement.count) item(s)") - - // Generate timelines - var entryDates : Set = [] - var entries: [SimpleEntry] = [] - for todayItem in arrangement { - entryDates.insert(todayItem.start_time) - entryDates.insert(todayItem.end_time) - } + + // 保留作者各分支的诊断日志,时间线仍统一按新的边界规则生成。 + let logsGeneratedEntries: Bool if #available(iOSApplicationExtension 17.0, *), IsTomorrowManager.value == true { logger.info("User wants tomorrow's arrangements") - entries.append(SimpleEntry( - date: Date(), - currentWeek: currentWeekToStore, - arrangement: arrangement, - errorType: .none, - error: nil - )) + logsGeneratedEntries = false } else if arrangement.isEmpty { logger.info("Arrangement data have no items") - entries.append(SimpleEntry( - date: Date(), - currentWeek: currentWeekToStore, - arrangement: arrangement, - errorType: .none, - error: nil - - )) + logsGeneratedEntries = false } else { logger.info("User wants today's arrangements, will remove occured arrangements") - for entryDate in entryDates { - entries.append(SimpleEntry( - date: entryDate, - currentWeek: currentWeekToStore, - arrangement: arrangement.filter{ - element in return element.end_time > entryDate - }, - errorType: .none, - error: nil - )) + logsGeneratedEntries = true + } + + // 排序后只预生成本日尚未发生的边界,并始终包含当前状态。 + let now = Date() + let midnight = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now))! + var entryDates: Set = [now] + for item in arrangement { + entryDates.formUnion([item.start_time, item.end_time].filter { $0 > now && $0 < midnight }) + } + var entries: [SimpleEntry] = [] + for date in entryDates.sorted() { + entries.append(SimpleEntry(date: date, currentWeek: currentWeekToStore, + arrangement: arrangement.filter { $0.end_time > date }, errorType: .none, error: nil)) + if logsGeneratedEntries { print("\(entries)") } } - + // 即使今日无课也在零点请求新数据,避免空状态停留到下一天。 logger.info("Updating timeline") - let timeline = Timeline(entries: entries, policy: .atEnd) + let timeline = Timeline(entries: entries, policy: .after(midnight)) completion(timeline) } } @@ -529,7 +516,7 @@ struct ClasstableWidgetEntryView : View { private func normalContentView() -> some View { // Calculate the date arrangements will show - var day = Date() + var day = entry.date let calendar = Calendar.current if #available(iOS 17.0, macOS 13.0, tvOS 17.0, watchOS 10.0, *), IsTomorrowManager.value { diff --git a/ios/ClasstableWidget/EventItem.swift b/ios/ClasstableWidget/EventItem.swift index e4cdacab..b31983d5 100644 --- a/ios/ClasstableWidget/EventItem.swift +++ b/ios/ClasstableWidget/EventItem.swift @@ -12,6 +12,7 @@ import Foundation import SwiftUI +import WidgetKit private let formatHourMinute = "HH:mm" private let myDateFormatter = DateFormatter() @@ -86,4 +87,3 @@ struct EventItem_Previews: PreviewProvider { } } */ - diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 4e9119cb..1bd69c40 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + A27B00000000000000000031 /* DayCourseLayoutCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A27B00000000000000000030 /* DayCourseLayoutCache.swift */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 262B016C2B58FED30094B372 /* SaveToGroupID.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = 262B016B2B58FED30094B372 /* SaveToGroupID.g.swift */; }; 262B01702B5905FA0094B372 /* ApiImplementation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 262B016F2B5905FA0094B372 /* ApiImplementation.swift */; }; @@ -34,6 +35,47 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + A17A00000000000000000013 /* TraintimeWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17A0000000000000000000B /* TraintimeWatchApp.swift */; }; + A17A00000000000000000014 /* WatchScheduleSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17A0000000000000000000C /* WatchScheduleSnapshot.swift */; }; + A17A00000000000000000015 /* WatchScheduleStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17A0000000000000000000D /* WatchScheduleStore.swift */; }; + A17A00000000000000000016 /* WatchConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17A0000000000000000000E /* WatchConnectivityManager.swift */; }; + A17A00000000000000000017 /* CourseViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17A0000000000000000000F /* CourseViews.swift */; }; + A17A00000000000000000018 /* WeekScheduleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17A00000000000000000010 /* WeekScheduleView.swift */; }; + A27B00000000000000000011 /* CourseListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A27B00000000000000000001 /* CourseListView.swift */; }; + A27B00000000000000000012 /* DayScheduleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A27B00000000000000000002 /* DayScheduleView.swift */; }; + A27B00000000000000000013 /* MonthScheduleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A27B00000000000000000003 /* MonthScheduleView.swift */; }; + A27B00000000000000000014 /* CalendarPagingSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A27B00000000000000000004 /* CalendarPagingSupport.swift */; }; + A27B00000000000000000015 /* InteractionAwareScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A27B00000000000000000005 /* InteractionAwareScrollView.swift */; }; + A27B00000000000000000016 /* WatchInteractionSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A27B00000000000000000006 /* WatchInteractionSupport.swift */; }; + A27B00000000000000000017 /* OverviewScheduleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A27B00000000000000000007 /* OverviewScheduleView.swift */; }; + A27B00000000000000000018 /* MonthCalendarData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A27B00000000000000000008 /* MonthCalendarData.swift */; }; + A27B00000000000000000019 /* WatchOnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A27B00000000000000000009 /* WatchOnboardingView.swift */; }; + A17A0000000000000000001C /* TraintimeWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = A17A00000000000000000002 /* TraintimeWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + A17A0000000000000000001D /* RootScheduleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17A00000000000000000011 /* RootScheduleView.swift */; }; + A17A0000000000000000001E /* WatchConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17A0000000000000000001F /* WatchConnectivityManager.swift */; }; + A17A00000000000000000020 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A17A00000000000000000021 /* Assets.xcassets */; }; + D27A00000000000000000002 /* PhoneWatchQueuedScheduleTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A00000000000000000001 /* PhoneWatchQueuedScheduleTransport.swift */; }; + B17B00000000000000000011 /* TraintimeWatchWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B17B0000000000000000000B /* TraintimeWatchWidgetBundle.swift */; }; + B17B00000000000000000012 /* TraintimeScheduleWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = B17B0000000000000000000C /* TraintimeScheduleWidget.swift */; }; + B17B00000000000000000013 /* WatchWidgetShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = B17B0000000000000000000D /* WatchWidgetShared.swift */; }; + B17B00000000000000000014 /* WatchWidgetShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = B17B0000000000000000000D /* WatchWidgetShared.swift */; }; + B17B00000000000000000015 /* WatchScheduleSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17A0000000000000000000C /* WatchScheduleSnapshot.swift */; }; + B17B00000000000000000016 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 26947AA22B49B50B006B2B17 /* WidgetKit.framework */; }; + B17B00000000000000000017 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 26947AA42B49B50B006B2B17 /* SwiftUI.framework */; }; + B17B00000000000000000018 /* TraintimeWatchWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B17B00000000000000000002 /* TraintimeWatchWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + C17C00000000000000000001 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = C17C00000000000000000003 /* Localizable.xcstrings */; }; + C17C00000000000000000002 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = C17C00000000000000000003 /* Localizable.xcstrings */; }; + F39F00000000000000000001 /* WatchSchedulePresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39F00000000000000000003 /* WatchSchedulePresentation.swift */; }; + F39F00000000000000000002 /* WatchSchedulePresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39F00000000000000000003 /* WatchSchedulePresentation.swift */; }; + F39F00000000000000000011 /* WatchSyncSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39F00000000000000000010 /* WatchSyncSupport.swift */; }; + F39F00000000000000000012 /* WatchSyncSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39F00000000000000000010 /* WatchSyncSupport.swift */; }; + F39F00000000000000000013 /* WatchSyncSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39F00000000000000000010 /* WatchSyncSupport.swift */; }; + F41B00000000000000000011 /* WidgetOnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F41B00000000000000000001 /* WidgetOnboardingView.swift */; }; + F41B00000000000000000012 /* WidgetIntroPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = F41B00000000000000000002 /* WidgetIntroPage.swift */; }; + F41B00000000000000000013 /* WidgetPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F41B00000000000000000003 /* WidgetPreviewView.swift */; }; + F41B00000000000000000014 /* WidgetInstallGuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F41B00000000000000000004 /* WidgetInstallGuideView.swift */; }; + F41B00000000000000000015 /* WatchWidgetDesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = F41B00000000000000000005 /* WatchWidgetDesignTokens.swift */; }; + F41B00000000000000000016 /* WatchWidgetDesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = F41B00000000000000000005 /* WatchWidgetDesignTokens.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -51,6 +93,20 @@ remoteGlobalIDString = 97C146ED1CF9000F007C117D; remoteInfo = Runner; }; + A17A00000000000000000019 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = A17A00000000000000000001; + remoteInfo = TraintimeWatch; + }; + B17B00000000000000000019 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = B17B00000000000000000001; + remoteInfo = TraintimeWatchWidgetExtension; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -75,9 +131,32 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; + A17A0000000000000000001B /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + A17A0000000000000000001C /* TraintimeWatch.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; + B17B0000000000000000001B /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + B17B00000000000000000018 /* TraintimeWatchWidgetExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + A27B00000000000000000030 /* DayCourseLayoutCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage/DayCourseLayoutCache.swift; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 262B016B2B58FED30094B372 /* SaveToGroupID.g.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SaveToGroupID.g.swift; sourceTree = ""; }; @@ -119,6 +198,42 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A17A00000000000000000002 /* TraintimeWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TraintimeWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A17A0000000000000000000B /* TraintimeWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TraintimeWatchApp.swift; sourceTree = ""; }; + A17A0000000000000000000C /* WatchScheduleSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models/WatchScheduleSnapshot.swift; sourceTree = ""; }; + A17A0000000000000000000D /* WatchScheduleStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage/WatchScheduleStore.swift; sourceTree = ""; }; + A17A0000000000000000000E /* WatchConnectivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity/WatchConnectivityManager.swift; sourceTree = ""; }; + A17A0000000000000000000F /* CourseViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/CourseViews.swift; sourceTree = ""; }; + A17A00000000000000000010 /* WeekScheduleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/WeekScheduleView.swift; sourceTree = ""; }; + A17A00000000000000000011 /* RootScheduleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/RootScheduleView.swift; sourceTree = ""; }; + A27B00000000000000000001 /* CourseListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/CourseListView.swift; sourceTree = ""; }; + A27B00000000000000000002 /* DayScheduleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/DayScheduleView.swift; sourceTree = ""; }; + A27B00000000000000000003 /* MonthScheduleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/MonthScheduleView.swift; sourceTree = ""; }; + A27B00000000000000000004 /* CalendarPagingSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/CalendarPagingSupport.swift; sourceTree = ""; }; + A27B00000000000000000005 /* InteractionAwareScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/InteractionAwareScrollView.swift; sourceTree = ""; }; + A27B00000000000000000006 /* WatchInteractionSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/WatchInteractionSupport.swift; sourceTree = ""; }; + A27B00000000000000000007 /* OverviewScheduleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/OverviewScheduleView.swift; sourceTree = ""; }; + A27B00000000000000000008 /* MonthCalendarData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/MonthCalendarData.swift; sourceTree = ""; }; + A27B00000000000000000009 /* WatchOnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/WatchOnboardingView.swift; sourceTree = ""; }; + A17A00000000000000000012 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + A17A0000000000000000001F /* WatchConnectivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchConnectivityManager.swift; sourceTree = ""; }; + A17A00000000000000000021 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + B17B00000000000000000002 /* TraintimeWatchWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TraintimeWatchWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + B17B0000000000000000000B /* TraintimeWatchWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Widget/TraintimeWatchWidgetBundle.swift; sourceTree = ""; }; + B17B0000000000000000000C /* TraintimeScheduleWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Widget/TraintimeScheduleWidget.swift; sourceTree = ""; }; + B17B0000000000000000000D /* WatchWidgetShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shared/WatchWidgetShared.swift; sourceTree = ""; }; + B17B0000000000000000000E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Widget/Info.plist; sourceTree = ""; }; + B17B0000000000000000000F /* TraintimeWatch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TraintimeWatch.entitlements; sourceTree = ""; }; + B17B00000000000000000010 /* TraintimeWatchWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Widget/TraintimeWatchWidgetExtension.entitlements; sourceTree = ""; }; + C17C00000000000000000003 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + D27A00000000000000000001 /* PhoneWatchQueuedScheduleTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneWatchQueuedScheduleTransport.swift; sourceTree = ""; }; + F39F00000000000000000003 /* WatchSchedulePresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shared/WatchSchedulePresentation.swift; sourceTree = ""; }; + F39F00000000000000000010 /* WatchSyncSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shared/WatchSyncSupport.swift; sourceTree = ""; }; + F41B00000000000000000001 /* WidgetOnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/Onboarding/WidgetOnboardingView.swift; sourceTree = ""; }; + F41B00000000000000000002 /* WidgetIntroPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/Onboarding/WidgetIntroPage.swift; sourceTree = ""; }; + F41B00000000000000000003 /* WidgetPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/Onboarding/WidgetPreviewView.swift; sourceTree = ""; }; + F41B00000000000000000004 /* WidgetInstallGuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views/Onboarding/WidgetInstallGuideView.swift; sourceTree = ""; }; + F41B00000000000000000005 /* WatchWidgetDesignTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shared/WatchWidgetDesignTokens.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -146,6 +261,22 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A17A00000000000000000008 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B17B00000000000000000008 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B17B00000000000000000017 /* SwiftUI.framework in Frameworks */, + B17B00000000000000000016 /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -195,6 +326,7 @@ 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 26947AA62B49B50B006B2B17 /* ClasstableWidget */, + A17A0000000000000000000A /* watchOS */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, D9D5E272809C25B343F9B947 /* Frameworks */, @@ -207,6 +339,8 @@ 97C146EE1CF9000F007C117D /* Runner.app */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, 26947AA12B49B50B006B2B17 /* ClasstableWidgetExtension.appex */, + A17A00000000000000000002 /* TraintimeWatch.app */, + B17B00000000000000000002 /* TraintimeWatchWidgetExtension.appex */, ); name = Products; sourceTree = ""; @@ -223,12 +357,55 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + A17A0000000000000000001F /* WatchConnectivityManager.swift */, + D27A00000000000000000001 /* PhoneWatchQueuedScheduleTransport.swift */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; sourceTree = ""; }; + A17A0000000000000000000A /* watchOS */ = { + isa = PBXGroup; + children = ( + A17A0000000000000000000B /* TraintimeWatchApp.swift */, + A17A0000000000000000000C /* WatchScheduleSnapshot.swift */, + A17A0000000000000000000D /* WatchScheduleStore.swift */, + A17A0000000000000000000E /* WatchConnectivityManager.swift */, + A17A0000000000000000000F /* CourseViews.swift */, + A27B00000000000000000001 /* CourseListView.swift */, + A27B00000000000000000007 /* OverviewScheduleView.swift */, + A27B00000000000000000002 /* DayScheduleView.swift */, + A17A00000000000000000010 /* WeekScheduleView.swift */, + A27B00000000000000000003 /* MonthScheduleView.swift */, + A27B00000000000000000008 /* MonthCalendarData.swift */, + A27B00000000000000000030 /* DayCourseLayoutCache.swift */, + A27B00000000000000000004 /* CalendarPagingSupport.swift */, + A27B00000000000000000005 /* InteractionAwareScrollView.swift */, + A27B00000000000000000006 /* WatchInteractionSupport.swift */, + A27B00000000000000000009 /* WatchOnboardingView.swift */, + F41B00000000000000000001 /* WidgetOnboardingView.swift */, + F41B00000000000000000002 /* WidgetIntroPage.swift */, + F41B00000000000000000003 /* WidgetPreviewView.swift */, + F41B00000000000000000004 /* WidgetInstallGuideView.swift */, + A17A00000000000000000011 /* RootScheduleView.swift */, + A17A00000000000000000021 /* Assets.xcassets */, + C17C00000000000000000003 /* Localizable.xcstrings */, + B17B0000000000000000000D /* WatchWidgetShared.swift */, + F39F00000000000000000003 /* WatchSchedulePresentation.swift */, + F39F00000000000000000010 /* WatchSyncSupport.swift */, + F41B00000000000000000005 /* WatchWidgetDesignTokens.swift */, + B17B0000000000000000000B /* TraintimeWatchWidgetBundle.swift */, + B17B0000000000000000000C /* TraintimeScheduleWidget.swift */, + B17B0000000000000000000E /* Info.plist */, + B17B0000000000000000000F /* TraintimeWatch.entitlements */, + B17B00000000000000000010 /* TraintimeWatchWidgetExtension.entitlements */, + A17A00000000000000000012 /* README.md */, + ); + name = watchOS; + path = ../watchOS; + sourceTree = ""; + }; D9D5E272809C25B343F9B947 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -285,6 +462,7 @@ 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 26947AB52B49B50C006B2B17 /* Embed Foundation Extensions */, + A17A0000000000000000001B /* Embed Watch Content */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, ); @@ -292,6 +470,7 @@ ); dependencies = ( 26947AAF2B49B50C006B2B17 /* PBXTargetDependency */, + A17A0000000000000000001A /* PBXTargetDependency */, ); name = Runner; packageProductDependencies = ( @@ -301,6 +480,42 @@ productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; + A17A00000000000000000001 /* TraintimeWatch */ = { + isa = PBXNativeTarget; + buildConfigurationList = A17A00000000000000000003 /* Build configuration list for PBXNativeTarget "TraintimeWatch" */; + buildPhases = ( + A17A00000000000000000007 /* Sources */, + A17A00000000000000000008 /* Frameworks */, + A17A00000000000000000009 /* Resources */, + B17B0000000000000000001B /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + B17B0000000000000000001A /* PBXTargetDependency */, + ); + name = TraintimeWatch; + productName = TraintimeWatch; + productReference = A17A00000000000000000002 /* TraintimeWatch.app */; + productType = "com.apple.product-type.application"; + }; + B17B00000000000000000001 /* TraintimeWatchWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = B17B00000000000000000003 /* Build configuration list for PBXNativeTarget "TraintimeWatchWidgetExtension" */; + buildPhases = ( + B17B00000000000000000007 /* Sources */, + B17B00000000000000000008 /* Frameworks */, + B17B00000000000000000009 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = TraintimeWatchWidgetExtension; + productName = TraintimeWatchWidgetExtension; + productReference = B17B00000000000000000002 /* TraintimeWatchWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -323,6 +538,12 @@ CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1100; }; + A17A00000000000000000001 = { + CreatedOnToolsVersion = 27.0; + }; + B17B00000000000000000001 = { + CreatedOnToolsVersion = 27.0; + }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; @@ -346,6 +567,8 @@ 97C146ED1CF9000F007C117D /* Runner */, 331C8080294A63A400263BE5 /* RunnerTests */, 26947AA02B49B50B006B2B17 /* ClasstableWidgetExtension */, + A17A00000000000000000001 /* TraintimeWatch */, + B17B00000000000000000001 /* TraintimeWatchWidgetExtension */, ); }; /* End PBXProject section */ @@ -378,6 +601,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A17A00000000000000000009 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A17A00000000000000000020 /* Assets.xcassets in Resources */, + C17C00000000000000000001 /* Localizable.xcstrings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B17B00000000000000000009 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C17C00000000000000000002 /* Localizable.xcstrings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -449,10 +689,59 @@ 262B016C2B58FED30094B372 /* SaveToGroupID.g.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 262B01702B5905FA0094B372 /* ApiImplementation.swift in Sources */, + A17A0000000000000000001E /* WatchConnectivityManager.swift in Sources */, + D27A00000000000000000002 /* PhoneWatchQueuedScheduleTransport.swift in Sources */, + F39F00000000000000000011 /* WatchSyncSupport.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; + A17A00000000000000000007 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A17A00000000000000000013 /* TraintimeWatchApp.swift in Sources */, + A17A00000000000000000014 /* WatchScheduleSnapshot.swift in Sources */, + A17A00000000000000000015 /* WatchScheduleStore.swift in Sources */, + A17A00000000000000000016 /* WatchConnectivityManager.swift in Sources */, + A17A00000000000000000017 /* CourseViews.swift in Sources */, + A27B00000000000000000011 /* CourseListView.swift in Sources */, + A27B00000000000000000017 /* OverviewScheduleView.swift in Sources */, + A27B00000000000000000012 /* DayScheduleView.swift in Sources */, + A17A00000000000000000018 /* WeekScheduleView.swift in Sources */, + A27B00000000000000000013 /* MonthScheduleView.swift in Sources */, + A27B00000000000000000018 /* MonthCalendarData.swift in Sources */, + A27B00000000000000000031 /* DayCourseLayoutCache.swift in Sources */, + A27B00000000000000000014 /* CalendarPagingSupport.swift in Sources */, + A27B00000000000000000015 /* InteractionAwareScrollView.swift in Sources */, + A27B00000000000000000016 /* WatchInteractionSupport.swift in Sources */, + A27B00000000000000000019 /* WatchOnboardingView.swift in Sources */, + A17A0000000000000000001D /* RootScheduleView.swift in Sources */, + B17B00000000000000000014 /* WatchWidgetShared.swift in Sources */, + F39F00000000000000000001 /* WatchSchedulePresentation.swift in Sources */, + F39F00000000000000000012 /* WatchSyncSupport.swift in Sources */, + F41B00000000000000000011 /* WidgetOnboardingView.swift in Sources */, + F41B00000000000000000012 /* WidgetIntroPage.swift in Sources */, + F41B00000000000000000013 /* WidgetPreviewView.swift in Sources */, + F41B00000000000000000014 /* WidgetInstallGuideView.swift in Sources */, + F41B00000000000000000015 /* WatchWidgetDesignTokens.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B17B00000000000000000007 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B17B00000000000000000011 /* TraintimeWatchWidgetBundle.swift in Sources */, + B17B00000000000000000012 /* TraintimeScheduleWidget.swift in Sources */, + B17B00000000000000000013 /* WatchWidgetShared.swift in Sources */, + F39F00000000000000000002 /* WatchSchedulePresentation.swift in Sources */, + F39F00000000000000000013 /* WatchSyncSupport.swift in Sources */, + F41B00000000000000000016 /* WatchWidgetDesignTokens.swift in Sources */, + B17B00000000000000000015 /* WatchScheduleSnapshot.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -466,6 +755,16 @@ target = 97C146ED1CF9000F007C117D /* Runner */; targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; }; + A17A0000000000000000001A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A17A00000000000000000001 /* TraintimeWatch */; + targetProxy = A17A00000000000000000019 /* PBXContainerItemProxy */; + }; + B17B0000000000000000001A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = B17B00000000000000000001 /* TraintimeWatchWidgetExtension */; + targetProxy = B17B00000000000000000019 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -542,7 +841,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.6; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; - STRIP_STYLE = "non-global"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = iphoneos; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; @@ -575,6 +874,7 @@ }; 26947AB22B49B50C006B2B17 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB31CF90195004384FC /* Generated.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -587,7 +887,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = ClasstableWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = YXS6PA6787; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -602,7 +902,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = xyz.superbart.xdyou.ClasstableWidget; @@ -618,6 +918,7 @@ }; 26947AB32B49B50C006B2B17 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB31CF90195004384FC /* Generated.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -630,7 +931,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = ClasstableWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = YXS6PA6787; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -645,7 +946,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = xyz.superbart.xdyou.ClasstableWidget; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -658,6 +959,7 @@ }; 26947AB42B49B50C006B2B17 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB31CF90195004384FC /* Generated.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -670,7 +972,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = ClasstableWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = YXS6PA6787; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -685,7 +987,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = xyz.superbart.xdyou.ClasstableWidget; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -798,7 +1100,7 @@ MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; - STRIP_STYLE = "non-global"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -852,7 +1154,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.6; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; - STRIP_STYLE = "non-global"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_EMIT_LOC_STRINGS = YES; @@ -909,6 +1211,180 @@ }; name = Release; }; + A17A00000000000000000004 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB31CF90195004384FC /* Generated.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = ../watchOS/TraintimeWatch.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = YXS6PA6787; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = XDYou; + INFOPLIST_KEY_WKApplication = YES; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = xyz.superbart.xdyou; + INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO; + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = xyz.superbart.xdyou.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + A17A00000000000000000005 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB31CF90195004384FC /* Generated.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = ../watchOS/TraintimeWatch.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = YXS6PA6787; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = XDYou; + INFOPLIST_KEY_WKApplication = YES; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = xyz.superbart.xdyou; + INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO; + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = xyz.superbart.xdyou.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + VALIDATE_PRODUCT = YES; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; + A17A00000000000000000006 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB31CF90195004384FC /* Generated.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = ../watchOS/TraintimeWatch.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = YXS6PA6787; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = XDYou; + INFOPLIST_KEY_WKApplication = YES; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = xyz.superbart.xdyou; + INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO; + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = xyz.superbart.xdyou.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Profile; + }; + B17B00000000000000000004 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB31CF90195004384FC /* Generated.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = ../watchOS/Widget/TraintimeWatchWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = YXS6PA6787; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ../watchOS/Widget/Info.plist; + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = xyz.superbart.xdyou.watchkitapp.widget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + B17B00000000000000000005 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB31CF90195004384FC /* Generated.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = ../watchOS/Widget/TraintimeWatchWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = YXS6PA6787; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ../watchOS/Widget/Info.plist; + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = xyz.superbart.xdyou.watchkitapp.widget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + VALIDATE_PRODUCT = YES; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; + B17B00000000000000000006 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB31CF90195004384FC /* Generated.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = ../watchOS/Widget/TraintimeWatchWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = YXS6PA6787; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ../watchOS/Widget/Info.plist; + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = xyz.superbart.xdyou.watchkitapp.widget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Profile; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -952,6 +1428,26 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A17A00000000000000000003 /* Build configuration list for PBXNativeTarget "TraintimeWatch" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A17A00000000000000000004 /* Debug */, + A17A00000000000000000005 /* Release */, + A17A00000000000000000006 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + B17B00000000000000000003 /* Build configuration list for PBXNativeTarget "TraintimeWatchWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B17B00000000000000000004 /* Debug */, + B17B00000000000000000005 /* Release */, + B17B00000000000000000006 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index c3fedb29..78f3f325 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,7 +1,7 @@ + version = "1.7"> @@ -91,6 +91,9 @@ ReferencedContainer = "container:Runner.xcodeproj"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 6aca3425..733de9ca 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -4,30 +4,75 @@ import flutter_local_notifications @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + /// 启动原生通知代理和 Apple Watch 通信,然后继续 Flutter 初始化。 override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate - return super.application(application, didFinishLaunchingWithOptions: launchOptions) + configureNotificationCenter() + activateWatchConnectivity() + return super.application( + application, + didFinishLaunchingWithOptions: launchOptions + ) } - - func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { - // This is required to make any communication available in the action isolate. + + /// Flutter 隐式引擎创建后注册插件和所有 Pigeon Host API。 + func didInitializeImplicitFlutterEngine( + _ engineBridge: FlutterImplicitEngineBridge + ) { + configureBackgroundPluginRegistrant() + registerGeneratedPlugins(with: engineBridge) + registerHostAPIs(with: engineBridge) + } + + /// 让前台通知和本地通知插件都使用当前 AppDelegate。 + private func configureNotificationCenter() { + UNUserNotificationCenter.current().delegate = + self as UNUserNotificationCenterDelegate + } + + /// 尽早激活 WCSession,使 Flutter 首次生成课表时可以立即发布。 + private func activateWatchConnectivity() { + PhoneWatchConnectivityManager.shared.activate() + } + + /// 后台通知 isolate 也必须注册 Flutter 插件。 + private func configureBackgroundPluginRegistrant() { FlutterLocalNotificationsPlugin.setPluginRegistrantCallback { (registry) in GeneratedPluginRegistrant.register(with: registry) } + } + + /// 注册 Flutter 自动生成的插件。 + private func registerGeneratedPlugins( + with engineBridge: FlutterImplicitEngineBridge + ) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) - + } + + /// 注册文件共享与 Apple Watch 同步两个原生 Host API。 + private func registerHostAPIs( + with engineBridge: FlutterImplicitEngineBridge + ) { + let messenger = engineBridge.applicationRegistrar.messenger() let api = ApiImplementation() - SaveToGroupIdSwiftApiSetup.setUp(binaryMessenger: engineBridge.applicationRegistrar.messenger(), api: api) + SaveToGroupIdSwiftApiSetup.setUp( + binaryMessenger: messenger, + api: api + ) + WatchSyncSwiftApiSetup.setUp( + binaryMessenger: messenger, + api: WatchSyncApiImplementation() + ) } - + + /// App 在前台时以横幅和通知中心列表展示课程提醒。 override func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { - completionHandler([.alert, .badge, .sound]) + completionHandler([.banner, .list, .badge, .sound]) } } diff --git a/ios/Runner/PhoneWatchQueuedScheduleTransport.swift b/ios/Runner/PhoneWatchQueuedScheduleTransport.swift new file mode 100644 index 00000000..339d62d2 --- /dev/null +++ b/ios/Runner/PhoneWatchQueuedScheduleTransport.swift @@ -0,0 +1,66 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import Foundation +import WatchConnectivity + +/// iPhone 端的 WatchConnectivity 后台队列适配器。 +/// +/// 实时 `sendMessage` 失败时,手表会改用 `transferUserInfo` 排队发送同一份 +/// 轻量课表请求。系统稍后在后台把请求交给 iPhone,本类型为请求补齐关联字段, +/// 再把原生课表管理器生成的回复通过 `transferUserInfo` 排队传回手表。 +/// +/// 这里不读取 Flutter 状态,也不持有课表数据;回复内容仍由 +/// `PhoneWatchConnectivityManager` 根据手机本地完整学期缓存统一生成。 +final class PhoneWatchQueuedScheduleTransport { + private typealias Key = WatchSyncProtocol.Key + private typealias MessageType = WatchSyncProtocol.MessageType + + /// 处理一条系统后台投递的课表请求。 + /// + /// - Parameters: + /// - userInfo: 手表排队发送的请求字典。 + /// - session: 当前已经激活的 WCSession。 + /// - makeReply: 复用实时通道的课表回复生成逻辑。 + func handle( + _ userInfo: [String: Any], + through session: WCSession, + makeReply: ([String: Any]) -> [String: Any]? + ) { + guard isQueuedScheduleRequest(userInfo), + let refreshID = nonemptyString( + userInfo[Key.refreshID] + ), + let requestID = nonemptyString( + userInfo[Key.requestID] + ), + var reply = makeReply(userInfo) + else { + return + } + + // refreshID 标识整轮“当天 → 14 天 → 学期”同步;requestID 标识其中 + // 的单个范围或分页请求。手表据此丢弃旧同步和重复的迟到回复。 + reply[Key.messageType] = MessageType.response + reply[Key.refreshID] = refreshID + reply[Key.requestID] = requestID + session.transferUserInfo(reply) + } + + /// 只接收本协议定义的队列请求,避免误处理其他业务的 UserInfo。 + private func isQueuedScheduleRequest( + _ userInfo: [String: Any] + ) -> Bool { + userInfo[Key.messageType] as? String == MessageType.request + } + + /// 统一过滤缺失或空白的关联标识。 + private func nonemptyString(_ value: Any?) -> String? { + guard let value = value as? String, + WatchScheduleText.nonempty(value) != nil + else { + return nil + } + return value + } +} diff --git a/ios/Runner/SaveToGroupID.g.swift b/ios/Runner/SaveToGroupID.g.swift index 1b364a3e..b4381dd0 100644 --- a/ios/Runner/SaveToGroupID.g.swift +++ b/ios/Runner/SaveToGroupID.g.swift @@ -1,7 +1,7 @@ // Copyright 2024 BenderBlog Rodriguez and contributors. // SPDX-License-Identifier: MPL-2.0 // -// Autogenerated from Pigeon (v26.3.4), do not edit directly. +// Autogenerated from Pigeon (v27.3.1), do not edit directly. // See also: https://pub.dev/packages/pigeon import Foundation @@ -58,127 +58,137 @@ private func wrapError(_ error: Any) -> [Any?] { ] } -private func isNullish(_ value: Any?) -> Bool { - return value is NSNull || value == nil -} +enum SaveToGroupIDPigeonInternal { + static func isNullish(_ value: Any?) -> Bool { + guard let innerValue = value else { + return true + } -private func nilOrValue(_ value: Any?) -> T? { - if value is NSNull { return nil } - return value as! T? -} + if case Optional.some(Optional.none) = value { + return true + } -private func doubleEqualsSaveToGroupID(_ lhs: Double, _ rhs: Double) -> Bool { - return (lhs.isNaN && rhs.isNaN) || lhs == rhs -} + return innerValue is NSNull + } + static func doubleEquals(_ lhs: Double, _ rhs: Double) -> Bool { + return (lhs.isNaN && rhs.isNaN) || lhs == rhs + } -private func doubleHashSaveToGroupID(_ value: Double, _ hasher: inout Hasher) { - if value.isNaN { - hasher.combine(0x7FF8000000000000) - } else { - // Normalize -0.0 to 0.0 - hasher.combine(value == 0 ? 0 : value) + static func doubleHash(_ value: Double, _ hasher: inout Hasher) { + if value.isNaN { + hasher.combine(0x7FF8000000000000) + } else { + // Normalize -0.0 to 0.0 + hasher.combine(value == 0 ? 0 : value) + } } -} -func deepEqualsSaveToGroupID(_ lhs: Any?, _ rhs: Any?) -> Bool { - let cleanLhs = nilOrValue(lhs) as Any? - let cleanRhs = nilOrValue(rhs) as Any? - switch (cleanLhs, cleanRhs) { - case (nil, nil): - return true + static func deepEquals(_ lhs: Any?, _ rhs: Any?) -> Bool { + let cleanLhs = nilOrValue(lhs) as Any? + let cleanRhs = nilOrValue(rhs) as Any? + switch (cleanLhs, cleanRhs) { + case (nil, nil): + return true - case (nil, _), (_, nil): - return false + case (nil, _), (_, nil): + return false - case (let lhs as AnyObject, let rhs as AnyObject) where lhs === rhs: - return true + case (let lhs as AnyObject, let rhs as AnyObject) where lhs === rhs: + return true - case is (Void, Void): - return true + case is (Void, Void): + return true - case (let lhsArray, let rhsArray) as ([Any?], [Any?]): - guard lhsArray.count == rhsArray.count else { return false } - for (index, element) in lhsArray.enumerated() { - if !deepEqualsSaveToGroupID(element, rhsArray[index]) { - return false + case (let lhsArray, let rhsArray) as ([Any?], [Any?]): + guard lhsArray.count == rhsArray.count else { return false } + for (index, element) in lhsArray.enumerated() { + if !deepEquals(element, rhsArray[index]) { + return false + } } - } - return true + return true - case (let lhsArray, let rhsArray) as ([Double], [Double]): - guard lhsArray.count == rhsArray.count else { return false } - for (index, element) in lhsArray.enumerated() { - if !doubleEqualsSaveToGroupID(element, rhsArray[index]) { - return false + case (let lhsArray, let rhsArray) as ([Double], [Double]): + guard lhsArray.count == rhsArray.count else { return false } + for (index, element) in lhsArray.enumerated() { + if !doubleEquals(element, rhsArray[index]) { + return false + } } - } - return true - - case (let lhsDictionary, let rhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]): - guard lhsDictionary.count == rhsDictionary.count else { return false } - for (lhsKey, lhsValue) in lhsDictionary { - var found = false - for (rhsKey, rhsValue) in rhsDictionary { - if deepEqualsSaveToGroupID(lhsKey, rhsKey) { - if deepEqualsSaveToGroupID(lhsValue, rhsValue) { - found = true - break - } else { - return false + return true + + case (let lhsDictionary, let rhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]): + guard lhsDictionary.count == rhsDictionary.count else { return false } + for (lhsKey, lhsValue) in lhsDictionary { + var found = false + for (rhsKey, rhsValue) in rhsDictionary { + if deepEquals(lhsKey, rhsKey) { + if deepEquals(lhsValue, rhsValue) { + found = true + break + } else { + return false + } } } + if !found { return false } } - if !found { return false } - } - return true + return true - case (let lhs as Double, let rhs as Double): - return doubleEqualsSaveToGroupID(lhs, rhs) + case (let lhs as Double, let rhs as Double): + return doubleEquals(lhs, rhs) - case (let lhsHashable, let rhsHashable) as (AnyHashable, AnyHashable): - return lhsHashable == rhsHashable + case (let lhsHashable, let rhsHashable) as (AnyHashable, AnyHashable): + return lhsHashable == rhsHashable - default: - return false + default: + return false + } } -} -func deepHashSaveToGroupID(value: Any?, hasher: inout Hasher) { - let cleanValue = nilOrValue(value) as Any? - if let cleanValue = cleanValue { - if let doubleValue = cleanValue as? Double { - doubleHashSaveToGroupID(doubleValue, &hasher) - } else if let valueList = cleanValue as? [Any?] { - for item in valueList { - deepHashSaveToGroupID(value: item, hasher: &hasher) - } - } else if let valueList = cleanValue as? [Double] { - for item in valueList { - doubleHashSaveToGroupID(item, &hasher) - } - } else if let valueDict = cleanValue as? [AnyHashable: Any?] { - var result = 0 - for (key, value) in valueDict { - var entryKeyHasher = Hasher() - deepHashSaveToGroupID(value: key, hasher: &entryKeyHasher) - var entryValueHasher = Hasher() - deepHashSaveToGroupID(value: value, hasher: &entryValueHasher) - result = result &+ ((entryKeyHasher.finalize() &* 31) ^ entryValueHasher.finalize()) + static func deepHash(value: Any?, hasher: inout Hasher) { + let cleanValue = nilOrValue(value) as Any? + if let cleanValue = cleanValue { + if let doubleValue = cleanValue as? Double { + doubleHash(doubleValue, &hasher) + } else if let valueList = cleanValue as? [Any?] { + for item in valueList { + deepHash(value: item, hasher: &hasher) + } + } else if let valueList = cleanValue as? [Double] { + for item in valueList { + doubleHash(item, &hasher) + } + } else if let valueDict = cleanValue as? [AnyHashable: Any?] { + var result = 0 + for (key, value) in valueDict { + var entryKeyHasher = Hasher() + deepHash(value: key, hasher: &entryKeyHasher) + var entryValueHasher = Hasher() + deepHash(value: value, hasher: &entryValueHasher) + result = result &+ ((entryKeyHasher.finalize() &* 31) ^ entryValueHasher.finalize()) + } + hasher.combine(result) + } else if let hashableValue = cleanValue as? AnyHashable { + hasher.combine(hashableValue) + } else { + hasher.combine(String(describing: cleanValue)) } - hasher.combine(result) - } else if let hashableValue = cleanValue as? AnyHashable { - hasher.combine(hashableValue) } else { - hasher.combine(String(describing: cleanValue)) + hasher.combine(0) } - } else { - hasher.combine(0) } + +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? } /// Generated class from Pigeon that represents data sent in messages. -struct FileToGroupID: Hashable { +struct FileToGroupID: Hashable, CustomStringConvertible { var appid: String var fileName: String var data: String @@ -207,14 +217,58 @@ struct FileToGroupID: Hashable { if Swift.type(of: lhs) != Swift.type(of: rhs) { return false } - return deepEqualsSaveToGroupID(lhs.appid, rhs.appid) && deepEqualsSaveToGroupID(lhs.fileName, rhs.fileName) && deepEqualsSaveToGroupID(lhs.data, rhs.data) + return SaveToGroupIDPigeonInternal.deepEquals(lhs.appid, rhs.appid) && SaveToGroupIDPigeonInternal.deepEquals(lhs.fileName, rhs.fileName) && SaveToGroupIDPigeonInternal.deepEquals(lhs.data, rhs.data) } func hash(into hasher: inout Hasher) { hasher.combine("FileToGroupID") - deepHashSaveToGroupID(value: appid, hasher: &hasher) - deepHashSaveToGroupID(value: fileName, hasher: &hasher) - deepHashSaveToGroupID(value: data, hasher: &hasher) + SaveToGroupIDPigeonInternal.deepHash(value: appid, hasher: &hasher) + SaveToGroupIDPigeonInternal.deepHash(value: fileName, hasher: &hasher) + SaveToGroupIDPigeonInternal.deepHash(value: data, hasher: &hasher) + } + + public var description: String { + return "FileToGroupID(appid: \(String(describing: appid)), fileName: \(String(describing: fileName)), data: \(String(describing: data)))" + } +} + +/// Flutter 发送给 iOS 原生层的自包含课表 JSON。 +/// +/// 使用单一载荷对象而不是散落参数,后续协议增加压缩或校验字段时可以保持 +/// Host API 方法签名稳定。 +/// +/// Generated class from Pigeon that represents data sent in messages. +struct WatchSchedulePayload: Hashable, CustomStringConvertible { + var json: String + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> WatchSchedulePayload? { + let json = pigeonVar_list[0] as! String + + return WatchSchedulePayload( + json: json + ) + } + func toList() -> [Any?] { + return [ + json + ] + } + static func == (lhs: WatchSchedulePayload, rhs: WatchSchedulePayload) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return SaveToGroupIDPigeonInternal.deepEquals(lhs.json, rhs.json) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("WatchSchedulePayload") + SaveToGroupIDPigeonInternal.deepHash(value: json, hasher: &hasher) + } + + public var description: String { + return "WatchSchedulePayload(json: \(String(describing: json)))" } } @@ -223,6 +277,8 @@ private class SaveToGroupIDPigeonCodecReader: FlutterStandardReader { switch type { case 129: return FileToGroupID.fromList(self.readValue() as! [Any?]) + case 130: + return WatchSchedulePayload.fromList(self.readValue() as! [Any?]) default: return super.readValue(ofType: type) } @@ -234,6 +290,9 @@ private class SaveToGroupIDPigeonCodecWriter: FlutterStandardWriter { if let value = value as? FileToGroupID { super.writeByte(129) super.writeValue(value.toList()) + } else if let value = value as? WatchSchedulePayload { + super.writeByte(130) + super.writeValue(value.toList()) } else { super.writeValue(value) } @@ -317,3 +376,75 @@ class SaveToGroupIdSwiftApiSetup { } } } +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol WatchSyncSwiftApi { + /// 将手机 App 当前实际使用的语言同步给 Apple Watch。 + func syncPreferredLanguage(localeIdentifier: String, completion: @escaping (Result) -> Void) + /// 保存最新学期快照,并通过 WatchConnectivity 发布给 Apple Watch。 + func syncSchedule(payload: WatchSchedulePayload, completion: @escaping (Result) -> Void) + /// 清除手机端持久化课表并向手表发布空上下文。 + func clearSchedule(signedOut: Bool, completion: @escaping (Result) -> Void) +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class WatchSyncSwiftApiSetup { + static var codec: FlutterStandardMessageCodec { SaveToGroupIDPigeonCodec.shared } + /// Sets up an instance of `WatchSyncSwiftApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: WatchSyncSwiftApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + /// 将手机 App 当前实际使用的语言同步给 Apple Watch。 + let syncPreferredLanguageChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.watermeter.WatchSyncSwiftApi.syncPreferredLanguage\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + syncPreferredLanguageChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let localeIdentifierArg = args[0] as! String + api.syncPreferredLanguage(localeIdentifier: localeIdentifierArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + syncPreferredLanguageChannel.setMessageHandler(nil) + } + /// 保存最新学期快照,并通过 WatchConnectivity 发布给 Apple Watch。 + let syncScheduleChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.watermeter.WatchSyncSwiftApi.syncSchedule\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + syncScheduleChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let payloadArg = args[0] as! WatchSchedulePayload + api.syncSchedule(payload: payloadArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + syncScheduleChannel.setMessageHandler(nil) + } + /// 清除手机端持久化课表并向手表发布空上下文。 + let clearScheduleChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.watermeter.WatchSyncSwiftApi.clearSchedule\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + clearScheduleChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let signedOutArg = args[0] as! Bool + api.clearSchedule(signedOut: signedOutArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + clearScheduleChannel.setMessageHandler(nil) + } + } +} diff --git a/ios/Runner/WatchConnectivityManager.swift b/ios/Runner/WatchConnectivityManager.swift new file mode 100644 index 00000000..3e18740d --- /dev/null +++ b/ios/Runner/WatchConnectivityManager.swift @@ -0,0 +1,848 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import CryptoKit +import Foundation +import WatchConnectivity + +/// 手机端支持的手表课表请求范围。 +private typealias PhoneScheduleScope = WatchScheduleScope + +/// 经过范围过滤或学期分页后的回复内容。 +private struct PhoneScheduleResponse { + let json: String + let nextOffset: Int + let hasMore: Bool +} + +/// 已解析的 JSON 根对象和课程数组。 +private struct PhoneScheduleDocument { + var root: [String: Any] + let courses: [[String: Any]] +} + +/// 更新手机本地完整课表后的判定结果。 +private struct StoredPhoneScheduleResult { + let version: String? + let revision: Int + let changed: Bool +} + +/// iPhone 端的 WatchConnectivity 管理器。 +/// +/// Flutter 负责生成完整学期 JSON;该管理器负责持久化、维护最近上下文, +/// 并按手表请求生成“当天、近 14 天、整学期分页”三种响应。 +final class PhoneWatchConnectivityManager: NSObject, WCSessionDelegate { + static let shared = PhoneWatchConnectivityManager() + + private typealias Key = WatchSyncProtocol.Key + + private static let persistedScheduleKey = + "TraintimeWatchSemesterSchedule" + private static let persistedLanguageKey = + "TraintimeWatchPreferredLanguage" + private static let persistedScheduleVersionKey = + "TraintimeWatchSemesterScheduleVersion" + private static let persistedRevisionKey = "TraintimeWatchStateRevision" + private static let persistedGenerationKey = "TraintimeWatchAccountGeneration" + private static let persistedSignedOutKey = "TraintimeWatchSignedOut" + private static let scheduleVersionPrefix = "v1:" + private static let semesterChunkSize = 50 + + /// WCSession 回调和 Flutter Pigeon 调用可能来自不同线程。 + private let stateLock = NSLock() + /// 生成回复、发布上下文和清理状态需要共享一次原子读取,防止新版本配上旧正文。 + /// 始终先获取此锁,再获取 stateLock;允许 clearSchedule 复用 syncSchedule。 + private let publicationLock = NSRecursiveLock() + private var accountGeneration = UserDefaults.standard.string(forKey: persistedGenerationKey) ?? UUID().uuidString + private var signedOut = UserDefaults.standard.bool(forKey: persistedSignedOutKey) + private var latestScheduleJSON: String? + /// 只在源 JSON 改变时解析;每个范围和分页复用这一份只读文档。 + private var latestScheduleDocument: PhoneScheduleDocument? + private var latestScheduleVersion: String? + private var latestPreferredLanguage: String? + private var latestRevision = UserDefaults.standard.integer(forKey: persistedRevisionKey) + /// 重试始终读取最新快照,只记录尚未成功发布的修订号,不另存一份正文。 + private var pendingScheduleRevision: Int? + + /// 把无法实时送达的手表请求转换为系统后台队列回复。 + private let queuedScheduleTransport = + PhoneWatchQueuedScheduleTransport() + + /// 启动时恢复上次完整学期缓存;坏缓存会被清理。 + private override init() { + let storedJSON = UserDefaults.standard.string( + forKey: Self.persistedScheduleKey + ) + let storedLanguage = UserDefaults.standard.string( + forKey: Self.persistedLanguageKey + ) + let document = storedJSON.flatMap(Self.parseScheduleDocument) + latestScheduleJSON = storedJSON + latestScheduleDocument = document + latestScheduleVersion = document.flatMap(Self.scheduleVersion) + latestPreferredLanguage = WatchLanguage(identifier: storedLanguage)?.rawValue + super.init() + latestRevision = max(latestRevision, Int(Date().timeIntervalSince1970 * 1_000)) + UserDefaults.standard.set(latestRevision, forKey: Self.persistedRevisionKey) + UserDefaults.standard.set(accountGeneration, forKey: Self.persistedGenerationKey) + + if storedJSON != nil, document == nil { + latestScheduleJSON = nil + latestScheduleVersion = nil + UserDefaults.standard.removeObject( + forKey: Self.persistedScheduleKey + ) + UserDefaults.standard.removeObject( + forKey: Self.persistedScheduleVersionKey + ) + log("Removed invalid persisted schedule") + } else if let latestScheduleVersion { + UserDefaults.standard.set( + latestScheduleVersion, + forKey: Self.persistedScheduleVersionKey + ) + } + } + + /// 激活与配对 Apple Watch 的系统会话。 + func activate() { + guard WCSession.isSupported() else { return } + configureAndActivate(WCSession.default) + } + + /// 接收 Flutter 生成的新学期快照。 + /// + /// 空字符串代表清空;非空字符串必须包含合法 JSON 根对象和课程数组。 + @discardableResult + func syncSchedule(json: String) -> Bool { + publicationLock.lock() + defer { publicationLock.unlock() } + let document = json.isEmpty ? nil : Self.parseScheduleDocument(json) + guard json.isEmpty || document != nil else { + log("Rejected invalid schedule JSON") + return false + } + + let result = storeLatestSchedule(json, document: document) + guard result.changed || withStateLock({ pendingScheduleRevision != nil }) else { + log("Schedule content unchanged; skipped republishing") + return true + } + // 语义相同的重试继续发布已保存正文,保证分页与上下文的生成时间也一致。 + let publicationJSON = currentLatestScheduleJSON() ?? "" + if result.changed { persistSchedule(publicationJSON, version: result.version) } + + guard WCSession.isSupported() else { return false } + let session = WCSession.default + guard session.activationState == .activated else { + setPendingSchedule(revision: result.revision) + configureAndActivate(session) + return true + } + + return updateApplicationContext( + json: publicationJSON, + version: result.version, + revision: result.revision, + session: session + ) + } + + /// 清除手机持久化数据,并向手表发布空上下文。 + @discardableResult + func clearSchedule(signedOut: Bool) -> Bool { + publicationLock.lock() + defer { publicationLock.unlock() } + withStateLock { + self.signedOut = signedOut + accountGeneration = UUID().uuidString + pendingScheduleRevision = nil + } + UserDefaults.standard.set(signedOut, forKey: Self.persistedSignedOutKey) + UserDefaults.standard.set(accountGeneration, forKey: Self.persistedGenerationKey) + if WCSession.isSupported() { + for transfer in WCSession.default.outstandingUserInfoTransfers { transfer.cancel() } + } + return syncSchedule(json: "") + } + + /// 保存手机当前实际生效的语言,并发布给配对 Apple Watch。 + /// + /// Application Context 负责手表离线时的最终一致性;当手表 App 当前可达 + /// 时,再额外发送一次实时消息,使切换语言后无需重新打开手表应用。 + @discardableResult + func syncPreferredLanguage(_ localeIdentifier: String) -> Bool { + publicationLock.lock() + defer { publicationLock.unlock() } + guard let language = WatchLanguage(identifier: localeIdentifier)?.rawValue else { + log("Rejected unsupported language: \(localeIdentifier)") + return false + } + + withStateLock { + latestPreferredLanguage = language + } + UserDefaults.standard.set( + language, + forKey: Self.persistedLanguageKey + ) + + guard WCSession.isSupported() else { return false } + let session = WCSession.default + guard session.activationState == .activated else { + configureAndActivate(session) + return true + } + return publishPreferredLanguage(using: session) + } + + /// 配置代理并激活 WCSession。 + private func configureAndActivate(_ session: WCSession) { + session.delegate = self + session.activate() + } + + /// 比较完整课表语义版本,仅在内容变化时更新内存状态。 + /// + /// `generatedAtEpochMs` 不参与版本计算,因此 App 重启或响应式 effect + /// 重建同一份课表时不会触发无意义的 WatchConnectivity 传输。 + private func storeLatestSchedule( + _ json: String, + document: PhoneScheduleDocument? + ) -> StoredPhoneScheduleResult { + let version = document.flatMap(Self.scheduleVersion) + return withStateLock { + guard json.isEmpty || version != latestScheduleVersion else { + return StoredPhoneScheduleResult( + version: version, + revision: latestRevision, + changed: false + ) + } + + latestRevision = max(latestRevision + 1, Int(Date().timeIntervalSince1970 * 1_000)) + UserDefaults.standard.set(latestRevision, forKey: Self.persistedRevisionKey) + if !json.isEmpty { + signedOut = false + UserDefaults.standard.set(false, forKey: Self.persistedSignedOutKey) + } + latestScheduleJSON = json.isEmpty ? nil : json + latestScheduleDocument = document + latestScheduleVersion = version + return StoredPhoneScheduleResult( + version: version, + revision: latestRevision, + changed: true + ) + } + } + + /// 把完整学期快照及其稳定版本号持久化到手机本地。 + private func persistSchedule(_ json: String, version: String?) { + if json.isEmpty { + UserDefaults.standard.removeObject( + forKey: Self.persistedScheduleKey + ) + UserDefaults.standard.removeObject( + forKey: Self.persistedScheduleVersionKey + ) + } else { + UserDefaults.standard.set( + json, + forKey: Self.persistedScheduleKey + ) + UserDefaults.standard.set( + version, + forKey: Self.persistedScheduleVersionKey + ) + } + } + + /// 只记录仍属于最新 revision 的待发送数据。 + private func setPendingSchedule(revision: Int) { + withStateLock { + guard revision == latestRevision else { return } + pendingScheduleRevision = revision + } + } + + /// 成功发送后只清除同一 revision,避免误删更新的数据。 + private func clearPendingSchedule(revision: Int) { + withStateLock { + guard pendingScheduleRevision == revision else { return } + pendingScheduleRevision = nil + } + } + + /// 读取最新完整学期 JSON 的线程安全副本。 + private func currentLatestScheduleJSON() -> String? { + withStateLock { latestScheduleJSON } + } + + /// 读取手机当前完整学期课表的稳定版本号。 + private func currentLatestScheduleVersion() -> String? { + withStateLock { latestScheduleVersion } + } + + /// 读取当前语言的线程安全副本。 + private func currentPreferredLanguage() -> String? { + withStateLock { latestPreferredLanguage } + } + + /// 使用 NSLock 保护闭包内的共享状态访问。 + private func withStateLock(_ body: () -> T) -> T { + stateLock.lock() + defer { stateLock.unlock() } + return body() + } + + /// 发布一个轻量的近 14 天 Application Context。 + /// + /// Application Context 是实时消息不可达时的离线回退,因此不发布整学期 + /// 大 JSON;手表主动打开后再通过 sendMessage 分页请求全部数据。 + private func updateApplicationContext( + json: String, + version: String?, + revision: Int, + session: WCSession + ) -> Bool { + publicationLock.lock() + defer { publicationLock.unlock() } + guard withStateLock({ revision == latestRevision }) else { return true } + let fallback = responsePayload( + sourceJSON: json, + scope: .fourteenDays, + offset: 0, + now: Date() + ) + + do { + let context = applicationContext(for: fallback, scheduleVersion: version) + try session.updateApplicationContext(context) + if json.isEmpty, session.isReachable { + session.sendMessage(context, replyHandler: nil, errorHandler: nil) + } + clearPendingSchedule(revision: revision) + return true + } catch { + setPendingSchedule(revision: revision) + log("Failed to update application context: \(error)") + return false + } + } + + /// 生成 Application Context 使用的协议字典。 + private func applicationContext( + for response: PhoneScheduleResponse, + scheduleVersion: String? + ) -> [String: Any] { + responseDictionary([ + Key.scheduleJSON: response.json, + Key.scope: PhoneScheduleScope.fourteenDays.rawValue, + ], scheduleVersion: scheduleVersion) + } + + /// 所有传输通道共用同一修订号和账户代次,语言更新也不能漏掉清空状态。 + private func stateMetadata() -> [String: Any] { + withStateLock { + [Key.stateRevision: latestRevision, + Key.accountGeneration: accountGeneration, + Key.scheduleCleared: latestScheduleJSON == nil, + Key.signedOut: signedOut] + } + } + + /// 在不覆盖已有课表上下文的前提下更新语言,并在可达时即时通知手表。 + private func publishPreferredLanguage(using session: WCSession) -> Bool { + publicationLock.lock() + defer { publicationLock.unlock() } + guard let language = currentPreferredLanguage() else { + return false + } + + let fallback = responsePayload(sourceJSON: currentLatestScheduleJSON() ?? "", scope: .fourteenDays, offset: 0, now: Date()) + var context = applicationContext(for: fallback, scheduleVersion: currentLatestScheduleVersion()) + context[Key.preferredLanguage] = language + + do { + try session.updateApplicationContext(context) + } catch { + log("Failed to update language context: \(error)") + return false + } + + sendPreferredLanguage(language, through: session) + return true + } + + /// 课表上下文本身已包含语言;即时消息只用于缩短前台语言切换的等待。 + private func sendPreferredLanguage(_ language: String, through session: WCSession) { + if session.isReachable { + session.sendMessage( + [Key.preferredLanguage: language], + replyHandler: nil + ) { [weak self] error in + self?.log( + "Failed to send immediate language update: \(error)" + ) + } + } + } + + /// 按请求范围过滤或分页,并更新响应中的覆盖区间。 + private func responsePayload( + sourceJSON: String, + scope: PhoneScheduleScope, + offset: Int, + now: Date + ) -> PhoneScheduleResponse { + // 值类型根字典在筛选时按需复制,已缓存的完整学期文档不会被修改。 + let cachedDocument = withStateLock { + sourceJSON == latestScheduleJSON ? latestScheduleDocument : nil + } + guard !sourceJSON.isEmpty, + var document = cachedDocument ?? Self.parseScheduleDocument(sourceJSON) + else { + return PhoneScheduleResponse( + json: sourceJSON, + nextOffset: 0, + hasMore: false + ) + } + + let calendar = WatchScheduleDate.calendar( + offsetMinutes: document.root["timeZoneOffsetMinutes"] as? Int) + document.root["sourceRevision"] = withStateLock { latestRevision } + if document.root["semesterEndEpochMs"] == nil { + document.root["semesterEndEpochMs"] = document.root["rangeEndEpochMs"] + } + let today = calendar.startOfDay(for: now) + let selection = selectCourses( + document: document, + scope: scope, + offset: offset, + today: today, + calendar: calendar + ) + + document.root["courses"] = selection.courses + updateRangeMetadata( + root: &document.root, + rangeStart: selection.rangeStart, + rangeEnd: selection.rangeEnd + ) + + guard let filteredJSON = serializeScheduleRoot(document.root) else { + return PhoneScheduleResponse( + json: sourceJSON, + nextOffset: 0, + hasMore: false + ) + } + return PhoneScheduleResponse( + json: filteredJSON, + nextOffset: selection.nextOffset, + hasMore: selection.hasMore + ) + } + + /// 课程筛选结果,包含同步范围和分页信息。 + private typealias CourseSelection = ( + courses: [[String: Any]], + rangeStart: Date, + rangeEnd: Date, + nextOffset: Int, + hasMore: Bool + ) + + /// 根据 scope 选择课程。 + private func selectCourses( + document: PhoneScheduleDocument, + scope: PhoneScheduleScope, + offset: Int, + today: Date, + calendar: Calendar + ) -> CourseSelection { + switch scope { + case .today: + return dateRangeSelection( + courses: document.courses, + start: today, + days: 1, + calendar: calendar + ) + case .fourteenDays: + return dateRangeSelection( + courses: document.courses, + start: today, + days: 14, + calendar: calendar + ) + case .semester: + return semesterSelection( + document: document, + offset: offset, + fallbackDate: today + ) + } + } + + /// 生成当天或近 14 天的左闭右开范围选择。 + private func dateRangeSelection( + courses: [[String: Any]], + start: Date, + days: Int, + calendar: Calendar + ) -> CourseSelection { + let end = calendar.date( + byAdding: .day, + value: days, + to: start + ) ?? start + return ( + courses: coursesInRange(courses, from: start, through: end), + rangeStart: start, + rangeEnd: end, + nextOffset: 0, + hasMore: false + ) + } + + /// 生成整学期的一个分页。 + private func semesterSelection( + document: PhoneScheduleDocument, + offset: Int, + fallbackDate: Date + ) -> CourseSelection { + let safeOffset = max(0, min(offset, document.courses.count)) + let endOffset = min( + safeOffset + Self.semesterChunkSize, + document.courses.count + ) + let rangeStart = date( + fromEpochMilliseconds: document.root["rangeStartEpochMs"] + ) ?? fallbackDate + let rangeEnd = date( + fromEpochMilliseconds: document.root["rangeEndEpochMs"] + ) ?? fallbackDate + + return ( + courses: Array(document.courses[safeOffset.. [[String: Any]] { + let startMilliseconds = epochMilliseconds(for: start) + let endMilliseconds = epochMilliseconds(for: end) + + return courses.filter { course in + guard let value = epochValue( + course["startAtEpochMs"] + ) else { + return false + } + return value >= startMilliseconds && value < endMilliseconds + } + } + + /// 将筛选后的范围写回 JSON 元数据。 + private func updateRangeMetadata( + root: inout [String: Any], + rangeStart: Date, + rangeEnd: Date + ) { + let sourceStart = date(fromEpochMilliseconds: root["rangeStartEpochMs"]) ?? rangeStart + let sourceEnd = date(fromEpochMilliseconds: root["rangeEndEpochMs"]) ?? rangeEnd + let sourceExpiry = date(fromEpochMilliseconds: root["validThroughEpochMs"]) ?? sourceEnd + let end = min(rangeEnd, sourceEnd) + let start = min(max(rangeStart, sourceStart), end) + root["rangeStartEpochMs"] = epochMilliseconds(for: start) + root["rangeEndEpochMs"] = epochMilliseconds(for: end) + root["validThroughEpochMs"] = epochMilliseconds(for: min(sourceExpiry, end)) + } + + /// 解析并验证课表根对象。 + private static func parseScheduleDocument( + _ json: String + ) -> PhoneScheduleDocument? { + guard let data = json.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data), + let root = object as? [String: Any], + let courses = root["courses"] as? [[String: Any]], + let schemaVersion = root["schemaVersion"] as? Int, + WatchSyncProtocol.supportedSchemaVersions.contains(schemaVersion) + else { + return nil + } + return PhoneScheduleDocument(root: root, courses: courses) + } + + /// 把更新后的根对象重新编码为 JSON。 + private func serializeScheduleRoot( + _ root: [String: Any] + ) -> String? { + guard JSONSerialization.isValidJSONObject(root), + let data = try? JSONSerialization.data( + withJSONObject: root + ) + else { + return nil + } + return String(data: data, encoding: .utf8) + } + + /// 为完整学期课表生成跨启动稳定的语义版本号。 + /// + /// JSON 使用排序键重新编码后计算 SHA-256;唯一被剔除的字段是每次构建 + /// 都变化、但不影响展示内容的 `generatedAtEpochMs`。课程、考试、实验、 + /// 周次、提醒、颜色或时间等任何实际字段变化都会产生新版本。 + private static func scheduleVersion(for document: PhoneScheduleDocument) -> String? { + var root = document.root + root.removeValue(forKey: "generatedAtEpochMs") + guard JSONSerialization.isValidJSONObject(root), + let canonicalData = try? JSONSerialization.data( + withJSONObject: root, + options: [.sortedKeys] + ) + else { + return nil + } + + let digest = SHA256.hash(data: canonicalData) + let hexadecimal = digest.map { + String(format: "%02x", $0) + }.joined() + return scheduleVersionPrefix + hexadecimal + } + + /// 从 JSON 的 NSNumber 字段读取毫秒时间戳。 + private func epochValue(_ value: Any?) -> Int64? { + (value as? NSNumber)?.int64Value + } + + /// 将毫秒时间戳转换为 Date。 + private func date(fromEpochMilliseconds value: Any?) -> Date? { + guard let milliseconds = epochValue(value) else { return nil } + return WatchScheduleDate.date(fromEpochMilliseconds: milliseconds) + } + + /// 将 Date 转换为跨语言使用的毫秒时间戳。 + private func epochMilliseconds(for date: Date) -> Int64 { + WatchScheduleDate.epochMilliseconds(for: date) + } + + /// WCSession 激活完成后重试最新待发送数据。 + func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + if let error { + log("Activation failed: \(error)") + return + } + guard activationState == .activated else { return } + + publicationLock.lock() + defer { publicationLock.unlock() } + _ = updateApplicationContext(json: currentLatestScheduleJSON() ?? "", + version: currentLatestScheduleVersion(), + revision: withStateLock { latestRevision }, session: session) + // 一次上下文已同时带上课表和语言,激活时无需再筛选、编码并发布同一范围。 + if let language = currentPreferredLanguage() { + sendPreferredLanguage(language, through: session) + } + } + + /// iOS 会话切换阶段无需额外处理。 + func sessionDidBecomeInactive(_ session: WCSession) {} + + /// 会话停用后按 Apple 建议重新激活。 + func sessionDidDeactivate(_ session: WCSession) { + session.activate() + } + + /// 响应手表主动发起的分阶段课表请求。 + func session( + _ session: WCSession, + didReceiveMessage message: [String: Any], + replyHandler: @escaping ([String: Any]) -> Void + ) { + replyHandler(makeScheduleReply(for: message) ?? [:]) + } + + /// 响应手表通过 `transferUserInfo` 排队发送的后台请求。 + /// + /// 队列适配器只负责关联请求和回传;回复正文仍走与实时消息完全相同的 + /// 版本比较、范围筛选和学期分页逻辑,避免两条传输路径产生数据差异。 + func session( + _ session: WCSession, + didReceiveUserInfo userInfo: [String: Any] + ) { + queuedScheduleTransport.handle( + userInfo, + through: session + ) { [weak self] request in + self?.makeScheduleReply(for: request) + } + } + + /// 为实时消息和后台队列生成同一格式的课表回复。 + private func makeScheduleReply( + for message: [String: Any] + ) -> [String: Any]? { + guard isScheduleRequest(message) else { return nil } + publicationLock.lock() + defer { publicationLock.unlock() } + + let scope = requestedScope(from: message) + let sourceJSON = currentLatestScheduleJSON() ?? "" + let scheduleVersion = currentLatestScheduleVersion() + + // 手表只上传它已经完整安装的版本号。相同则用一个轻量回复结束, + // 不生成当天/14 天 JSON,更不会启动整学期分页传输。 + if let scheduleVersion, + requestedScheduleVersion(from: message) == scheduleVersion, + message[Key.accountGeneration] as? String == withStateLock({ accountGeneration }) + { + return unchangedReplyDictionary( + scope: scope, + scheduleVersion: scheduleVersion + ) + } + + let response = responsePayload( + sourceJSON: sourceJSON, + scope: scope, + offset: requestedOffset(from: message), + now: Date() + ) + return replyDictionary( + response: response, + scope: scope, + scheduleVersion: scheduleVersion + ) + } + + /// 判断消息是否为课表请求。 + private func isScheduleRequest(_ message: [String: Any]) -> Bool { + message[Key.requestSchedule] as? Bool == true + } + + /// 解析请求范围,无法识别时默认提供近 14 天数据。 + private func requestedScope( + from message: [String: Any] + ) -> PhoneScheduleScope { + PhoneScheduleScope( + rawValue: message[Key.scope] as? String ?? "" + ) ?? .fourteenDays + } + + /// 解析分页偏移,缺失时从第一页开始。 + private func requestedOffset( + from message: [String: Any] + ) -> Int { + message[Key.offset] as? Int ?? 0 + } + + /// 读取手表已经完整安装的课表版本;请求本身不携带任何课表正文。 + private func requestedScheduleVersion( + from message: [String: Any] + ) -> String? { + message[Key.scheduleVersion] as? String + } + + /// 生成返回给手表的协议字典。 + private func replyDictionary( + response: PhoneScheduleResponse, + scope: PhoneScheduleScope, + scheduleVersion: String? + ) -> [String: Any] { + responseDictionary([ + Key.scheduleJSON: response.json, + Key.scope: scope.rawValue, + Key.nextOffset: response.nextOffset, + Key.hasMore: response.hasMore, + ], scheduleVersion: scheduleVersion) + } + + /// 版本一致时返回的轻量确认,不包含课表 JSON。 + private func unchangedReplyDictionary( + scope: PhoneScheduleScope, + scheduleVersion: String + ) -> [String: Any] { + responseDictionary([ + Key.scope: scope.rawValue, + Key.scheduleUnchanged: true, + Key.hasMore: false, + Key.nextOffset: 0, + ], scheduleVersion: scheduleVersion) + } + + /// 轻量确认、分页回复和 Application Context 共用版本、语言和账户状态封装。 + private func responseDictionary( + _ body: [String: Any], + scheduleVersion: String? + ) -> [String: Any] { + var reply = body + if let scheduleVersion { reply[Key.scheduleVersion] = scheduleVersion } + if let language = currentPreferredLanguage() { + reply[Key.preferredLanguage] = language + } + reply.merge(stateMetadata()) { _, new in new } + return reply + } + + /// 统一输出 WatchConnectivity 日志。 + private func log(_ message: String) { + NSLog("[WatchConnectivity] \(message)") + } +} + +/// Pigeon Host API 的薄适配层。 +/// +/// 这里只负责把异步完成回调桥接到管理器,业务逻辑全部留在可审计的管理器中。 +final class WatchSyncApiImplementation: WatchSyncSwiftApi { + /// 保存手机实际生效的语言并通知手表。 + func syncPreferredLanguage( + localeIdentifier: String, + completion: @escaping (Result) -> Void + ) { + let accepted = + PhoneWatchConnectivityManager.shared.syncPreferredLanguage( + localeIdentifier + ) + completion(.success(accepted)) + } + + /// 保存并发布新课表。 + func syncSchedule( + payload: WatchSchedulePayload, + completion: @escaping (Result) -> Void + ) { + let accepted = PhoneWatchConnectivityManager.shared.syncSchedule( + json: payload.json + ) + completion(.success(accepted)) + } + + /// 清除手机和手表课表。 + func clearSchedule( + signedOut: Bool, + completion: @escaping (Result) -> Void + ) { + let accepted = + PhoneWatchConnectivityManager.shared.clearSchedule(signedOut: signedOut) + completion(.success(accepted)) + } +} diff --git a/lib/bridge/save_to_groupid.g.dart b/lib/bridge/save_to_groupid.g.dart index 96603504..45faa16f 100644 --- a/lib/bridge/save_to_groupid.g.dart +++ b/lib/bridge/save_to_groupid.g.dart @@ -1,7 +1,7 @@ // Copyright 2024 BenderBlog Rodriguez and contributors. // SPDX-License-Identifier: MPL-2.0 // -// Autogenerated from Pigeon (v26.3.4), do not edit directly. +// Autogenerated from Pigeon (v27.3.1), do not edit directly. // See also: https://pub.dev/packages/pigeon // ignore_for_file: unused_import, unused_shown_name // ignore_for_file: type=lint @@ -13,9 +13,9 @@ import 'package:flutter/services.dart'; import 'package:meta/meta.dart' show immutable, protected, visibleForTesting; Object? _extractReplyValueOrThrow( - List? replyList, - String channelName, { - required bool isNullValid, + List? replyList, + String channelName, { + required bool isNullValid, }) { if (replyList == null) { throw PlatformException( @@ -49,8 +49,9 @@ bool _deepEquals(Object? a, Object? b) { } if (a is List && b is List) { return a.length == b.length && - a.indexed - .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + a.indexed.every( + ((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]), + ); } if (a is Map && b is Map) { if (a.length != b.length) { @@ -99,7 +100,6 @@ int _deepHash(Object? value) { return value.hashCode; } - class FileToGroupID { FileToGroupID({ required this.appid, @@ -114,15 +114,12 @@ class FileToGroupID { String data; List _toList() { - return [ - appid, - fileName, - data, - ]; + return [appid, fileName, data]; } Object encode() { - return _toList(); } + return _toList(); + } static FileToGroupID decode(Object result) { result as List; @@ -142,14 +139,64 @@ class FileToGroupID { if (identical(this, other)) { return true; } - return _deepEquals(appid, other.appid) && _deepEquals(fileName, other.fileName) && _deepEquals(data, other.data); + return _deepEquals(appid, other.appid) && + _deepEquals(fileName, other.fileName) && + _deepEquals(data, other.data); } @override // ignore: avoid_equals_and_hash_code_on_mutable_classes int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'FileToGroupID(appid: $appid, fileName: $fileName, data: $data)'; + } } +/// Flutter 发送给 iOS 原生层的自包含课表 JSON。 +/// +/// 使用单一载荷对象而不是散落参数,后续协议增加压缩或校验字段时可以保持 +/// Host API 方法签名稳定。 +class WatchSchedulePayload { + WatchSchedulePayload({required this.json}); + + String json; + + List _toList() { + return [json]; + } + + Object encode() { + return _toList(); + } + + static WatchSchedulePayload decode(Object result) { + result as List; + return WatchSchedulePayload(json: result[0]! as String); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! WatchSchedulePayload || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(json, other.json); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'WatchSchedulePayload(json: $json)'; + } +} class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @@ -158,9 +205,12 @@ class _PigeonCodec extends StandardMessageCodec { if (value is int) { buffer.putUint8(4); buffer.putInt64(value); - } else if (value is FileToGroupID) { + } else if (value is FileToGroupID) { buffer.putUint8(129); writeValue(buffer, value.encode()); + } else if (value is WatchSchedulePayload) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -171,6 +221,8 @@ class _PigeonCodec extends StandardMessageCodec { switch (type) { case 129: return FileToGroupID.decode(readValue(buffer)!); + case 130: + return WatchSchedulePayload.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } @@ -178,12 +230,16 @@ class _PigeonCodec extends StandardMessageCodec { } class SaveToGroupIdSwiftApi { - /// Constructor for [SaveToGroupIdSwiftApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [SaveToGroupIdSwiftApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. - SaveToGroupIdSwiftApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) - : pigeonVar_binaryMessenger = binaryMessenger, - pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + SaveToGroupIdSwiftApi({ + BinaryMessenger? binaryMessenger, + String messageChannelSuffix = '', + }) : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty + ? '.$messageChannelSuffix' + : ''; final BinaryMessenger? pigeonVar_binaryMessenger; static const MessageCodec pigeonChannelCodec = _PigeonCodec(); @@ -191,7 +247,8 @@ class SaveToGroupIdSwiftApi { final String pigeonVar_messageChannelSuffix; Future getHostLanguage() async { - final pigeonVar_channelName = 'dev.flutter.pigeon.watermeter.SaveToGroupIdSwiftApi.getHostLanguage$pigeonVar_messageChannelSuffix'; + final pigeonVar_channelName = + 'dev.flutter.pigeon.watermeter.SaveToGroupIdSwiftApi.getHostLanguage$pigeonVar_messageChannelSuffix'; final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, @@ -201,49 +258,136 @@ class SaveToGroupIdSwiftApi { final pigeonVar_replyList = await pigeonVar_sendFuture as List?; final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( - pigeonVar_replyList, - pigeonVar_channelName, - isNullValid: false, - ) - ; + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); return pigeonVar_replyValue! as String; } Future saveToGroupId(FileToGroupID data) async { - final pigeonVar_channelName = 'dev.flutter.pigeon.watermeter.SaveToGroupIdSwiftApi.saveToGroupId$pigeonVar_messageChannelSuffix'; + final pigeonVar_channelName = + 'dev.flutter.pigeon.watermeter.SaveToGroupIdSwiftApi.saveToGroupId$pigeonVar_messageChannelSuffix'; final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([data]); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [data], + ); final pigeonVar_replyList = await pigeonVar_sendFuture as List?; final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( - pigeonVar_replyList, - pigeonVar_channelName, - isNullValid: false, - ) - ; + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); return pigeonVar_replyValue! as bool; } Future deleteFromGroupId(FileToGroupID data) async { - final pigeonVar_channelName = 'dev.flutter.pigeon.watermeter.SaveToGroupIdSwiftApi.deleteFromGroupId$pigeonVar_messageChannelSuffix'; + final pigeonVar_channelName = + 'dev.flutter.pigeon.watermeter.SaveToGroupIdSwiftApi.deleteFromGroupId$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [data], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as bool; + } +} + +class WatchSyncSwiftApi { + /// Constructor for [WatchSyncSwiftApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + WatchSyncSwiftApi({ + BinaryMessenger? binaryMessenger, + String messageChannelSuffix = '', + }) : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty + ? '.$messageChannelSuffix' + : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + /// 将手机 App 当前实际使用的语言同步给 Apple Watch。 + Future syncPreferredLanguage(String localeIdentifier) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.watermeter.WatchSyncSwiftApi.syncPreferredLanguage$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [localeIdentifier], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as bool; + } + + /// 保存最新学期快照,并通过 WatchConnectivity 发布给 Apple Watch。 + Future syncSchedule(WatchSchedulePayload payload) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.watermeter.WatchSyncSwiftApi.syncSchedule$pigeonVar_messageChannelSuffix'; final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([data]); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [payload], + ); final pigeonVar_replyList = await pigeonVar_sendFuture as List?; final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( - pigeonVar_replyList, - pigeonVar_channelName, - isNullValid: false, - ) - ; + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as bool; + } + + /// 清除手机端持久化课表并向手表发布空上下文。 + Future clearSchedule(bool signedOut) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.watermeter.WatchSyncSwiftApi.clearSchedule$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [signedOut], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); return pigeonVar_replyValue! as bool; } } diff --git a/lib/controller/homepage_controller.dart b/lib/controller/homepage_controller.dart index 5368efd7..33f3cb29 100644 --- a/lib/controller/homepage_controller.dart +++ b/lib/controller/homepage_controller.dart @@ -23,6 +23,7 @@ import 'package:watermeter/repository/notification/course_reminder_service.dart' import 'package:watermeter/repository/preference.dart' as preference; import 'package:watermeter/repository/system_calendar_sync_service.dart'; import 'package:watermeter/repository/widget_state_sync.dart'; +import 'package:watermeter/repository/watch/watch_schedule_sync_service.dart'; enum ArrangementState { fetching, fetched, error, none } @@ -101,6 +102,7 @@ class HomepageController { bool forceRetryLogin = false, required Future Function(String) sliderCaptcha, }) async { + final watchSession = WatchScheduleSyncService.instance.sessionRevision; if (forceRetryLogin || loginState == IDSLoginState.fail) { await _comboLogin(sliderCaptcha: sliderCaptcha); } @@ -135,7 +137,10 @@ class HomepageController { final hasCredential = preference.getString(preference.Preference.idsAccount).isNotEmpty && preference.getString(preference.Preference.idsPassword).isNotEmpty; - await syncWidgetLoginState(hasCredential); + await syncWidgetLoginState( + hasCredential, + expectedWatchSession: watchSession, + ); } List _sortArrangements(Iterable data) { diff --git a/lib/controller/theme_controller.dart b/lib/controller/theme_controller.dart index d876fdea..a0621d4f 100644 --- a/lib/controller/theme_controller.dart +++ b/lib/controller/theme_controller.dart @@ -24,6 +24,13 @@ class ThemeController { final colorStateSignal = signal(ThemeMode.system); final localeSignal = signal(const Locale("zh", "CN")); + + /// 手机当前实际使用的语言代码。 + /// + /// 与设置项的 `zh_CN / zh_TW / en_US` 格式保持一致。选择“跟随系统”时, + /// `updateTheme` 会先解析系统语言再写入该 Signal,因此 Apple Watch 收到 + /// 的始终是明确语言,而不是无法解释的空字符串。 + final localeIdentifierSignal = signal("zh_CN"); final colorSignal = signal>([pdaColorScheme.first]); final fontScaleSignal = signal(defaultFontScale); final fontWeightSignal = signal(defaultFontWeight); @@ -94,6 +101,7 @@ class ThemeController { } } log.info("[ThemeController] Locale to set $localization"); + localeIdentifierSignal.value = localization; localeSignal.value = Locale.fromSubtags(languageCode: localization); } } diff --git a/lib/main.dart b/lib/main.dart index 53556fa8..74c9bb05 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -26,6 +26,7 @@ import 'package:watermeter/repository/notification/notification_registrar.dart'; import 'package:watermeter/repository/preference.dart' as preference; import 'package:watermeter/page/homepage/home.dart'; import 'package:watermeter/page/login/login_window.dart'; +import 'package:watermeter/repository/watch/watch_schedule_sync_service.dart'; import 'package:watermeter/repository/ids_session/ids_session.dart'; import 'package:watermeter/themes/font_setting.dart'; import 'package:home_widget/home_widget.dart'; @@ -60,6 +61,10 @@ void main() async { // Load package info. preference.packageInfo = await PackageInfo.fromPlatform(); + // iPhone 始终作为 Apple Watch 课表的数据源;服务会监听课程、考试、 + // 实验和周次变化,并在后台把完整学期快照同步给手表。 + WatchScheduleSyncService.instance.start(); + // Have user registered? String username = preference.getString(preference.Preference.idsAccount); String password = preference.getString(preference.Preference.idsPassword); diff --git a/lib/page/setting/groups/core_section.dart b/lib/page/setting/groups/core_section.dart index bcaf21e2..6ba85caa 100644 --- a/lib/page/setting/groups/core_section.dart +++ b/lib/page/setting/groups/core_section.dart @@ -25,6 +25,7 @@ import 'package:watermeter/repository/logger.dart'; import 'package:watermeter/repository/network_client.dart'; import 'package:watermeter/repository/preference.dart' as preference; import 'package:watermeter/repository/widget_state_sync.dart'; +import 'package:watermeter/repository/watch/watch_schedule_sync_service.dart'; class CoreSection extends StatelessWidget { const CoreSection({super.key}); @@ -80,6 +81,8 @@ class CoreSection extends StatelessWidget { ), ); + await WatchScheduleSyncService.instance.clear(); + /// Clean Cookie try { await NetworkCookieJars.ids.deleteAll(); @@ -161,6 +164,8 @@ class CoreSection extends StatelessWidget { ), ); + await syncWidgetLoginState(false); + /// Clean Cookie try { await NetworkCookieJars.ids.deleteAll(); @@ -200,9 +205,6 @@ class CoreSection extends StatelessWidget { /// Theme back to default ThemeController.i.updateTheme(); - /// Sync widget login state - await syncWidgetLoginState(false); - /// Clean iOS widget data files await clearWidgetFiles(); diff --git a/lib/repository/preference.dart b/lib/repository/preference.dart index de461996..6170ccf5 100644 --- a/lib/repository/preference.dart +++ b/lib/repository/preference.dart @@ -21,6 +21,10 @@ final GlobalKey splitViewKey = GlobalKey( debugLabel: "PDASplitKey", ); final GlobalKey leftKey = GlobalKey(); + +/// iOS 主 App、小组件和 Apple Watch 共享文件时使用的 App Group。 +/// +/// 修改该值时还必须同步修改各 Target 的 entitlements 与原生 Swift 常量。 const String appId = "group.xyz.superbart.xdyou"; Catcher2Options catcherOptions = Catcher2Options( diff --git a/lib/repository/watch/watch_schedule_snapshot.dart b/lib/repository/watch/watch_schedule_snapshot.dart new file mode 100644 index 00000000..e77c8dcc --- /dev/null +++ b/lib/repository/watch/watch_schedule_snapshot.dart @@ -0,0 +1,592 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import 'dart:convert'; + +import 'package:watermeter/model/pda_service/custom_class.dart'; +import 'package:watermeter/model/time_list.dart'; +import 'package:watermeter/model/xidian_ids/classtable.dart'; +import 'package:watermeter/model/xidian_ids/exam.dart'; +import 'package:watermeter/model/xidian_ids/experiment.dart'; + +/// 手机端发送给 Apple Watch 的日程类型。 +/// +/// 枚举名称会通过 `kind.name` 写入 JSON,修改已有枚举值会破坏旧版手表兼容性。 +enum WatchScheduleEntryKind { course, exam, physicsExperiment, otherExperiment } + +/// 已经展开到具体日期和时间的一条手表日程。 +/// +/// 手机课表中的常规课程通常使用“第几周、星期几、第几节”的重复规则; +/// Apple Watch 不再解释这些规则,而是直接接收可显示的具体起止时间。 +class WatchCourseOccurrence { + const WatchCourseOccurrence({ + required this.id, + required this.name, + required this.startAt, + required this.endAt, + required this.startSection, + required this.endSection, + required this.colorARGB, + required this.kind, + this.teacher, + this.classroom, + this.note, + }); + + /// 在一次完整学期同步中保持唯一的日程标识。 + final String id; + final String name; + final DateTime startAt; + final DateTime endAt; + final int startSection; + final int endSection; + final int colorARGB; + final WatchScheduleEntryKind kind; + final String? teacher; + final String? classroom; + final String? note; + + /// 转为 Swift `Codable` 模型所期望的 JSON 字段。 + Map toJson() => { + 'id': id, + 'name': name, + 'teacher': teacher, + 'classroom': classroom, + 'startAtEpochMs': startAt.millisecondsSinceEpoch, + 'endAtEpochMs': endAt.millisecondsSinceEpoch, + 'startSection': startSection, + 'endSection': endSection, + 'colorARGB': colorARGB, + 'kind': kind.name, + 'note': note, + }; +} + +/// 一次同步阶段的自包含课表快照。 +/// +/// 快照带有覆盖范围和有效期,手表可以在手机不在线时自行选择当天、 +/// 近 14 天或整学期缓存,不需要依赖 Flutter 进程继续运行。 +class WatchScheduleSnapshot { + const WatchScheduleSnapshot({ + required this.generatedAt, + required this.semesterStart, + required this.currentWeekIndex, + required this.validThrough, + required this.rangeStart, + required this.rangeEnd, + required this.reminderMinutes, + required this.courses, + this.semesterEnd, + }); + + /// 每次修改 JSON 字段语义时必须递增,并同步更新 Swift 支持范围。 + static const schemaVersion = 4; + + final DateTime generatedAt; + final DateTime semesterStart; + final DateTime? semesterEnd; + final int currentWeekIndex; + final DateTime validThrough; + final DateTime rangeStart; + final DateTime rangeEnd; + final int reminderMinutes; + final List courses; + + /// 生成稳定、跨语言的 JSON 结构。 + Map toJson() => { + 'schemaVersion': schemaVersion, + 'generatedAtEpochMs': generatedAt.millisecondsSinceEpoch, + 'semesterStartEpochMs': semesterStart.millisecondsSinceEpoch, + 'currentWeekIndex': currentWeekIndex, + 'validThroughEpochMs': validThrough.millisecondsSinceEpoch, + 'rangeStartEpochMs': rangeStart.millisecondsSinceEpoch, + 'rangeEndEpochMs': rangeEnd.millisecondsSinceEpoch, + 'semesterEndEpochMs': (semesterEnd ?? rangeEnd).millisecondsSinceEpoch, + 'timeZoneOffsetMinutes': generatedAt.timeZoneOffset.inMinutes, + 'reminderMinutes': reminderMinutes, + 'courses': courses.map((course) => course.toJson()).toList(), + }; + + /// 编码后交给 Pigeon 和原生 WatchConnectivity 层。 + String encode() => jsonEncode(toJson()); +} + +/// 构建过程中使用的左闭右开日期范围 `[start, end)`。 +final class _ScheduleWindow { + const _ScheduleWindow({required this.start, required this.end}); + + final DateTime start; + final DateTime end; + + /// 只按日程开始时间决定归属,保持与周/日视图现有行为一致。 + bool contains(DateTime date) => !date.isBefore(start) && date.isBefore(end); +} + +/// 把手机端的所有课程来源展开成 Apple Watch 可直接消费的快照。 +class WatchScheduleSnapshotBuilder { + const WatchScheduleSnapshotBuilder(); + + // 标准节次只解析一次,展开整学期时复用分钟值。 + static final List _periodMinutes = timeList + .map(_minutesFromClockText) + .toList(growable: false); + + /// Material 课程色的精确 ARGB 值,与手机端 `color_seed.dart` 顺序一致。 + static const _courseColors = [ + 0xFFF44336, + 0xFFE91E63, + 0xFF9C27B0, + 0xFF673AB7, + 0xFF3F51B5, + 0xFF2196F3, + 0xFF03A9F4, + 0xFF00BCD4, + 0xFF009688, + 0xFF4CAF50, + 0xFF8BC34A, + 0xFFCDDC39, + 0xFFFFEB3B, + 0xFFFF9800, + 0xFFFF5722, + 0xFF795548, + ]; + + /// 构建指定日期范围内的完整日程。 + /// + /// 四种来源分别展开,最后统一排序。每个辅助函数只负责一种模型, + /// 以后新增日程类型时不会继续扩大这个入口函数。 + WatchScheduleSnapshot build({ + required ClassTableData classTable, + required DateTime effectiveTermStart, + required int currentWeekIndex, + required DateTime now, + List customClasses = const [], + List subjects = const [], + List experiments = const [], + DateTime? rangeStart, + int days = 14, + int reminderMinutes = 5, + }) { + _validateDays(days); + + final semesterStart = _startOfDay(effectiveTermStart); + final window = _makeWindow(requestedStart: rangeStart ?? now, days: days); + final occurrences = [ + ..._buildSchoolCourses( + classTable: classTable, + semesterStart: semesterStart, + window: window, + ), + ..._buildCustomCourses( + classTable: classTable, + customClasses: customClasses, + window: window, + ), + ..._buildExams(subjects: subjects, window: window), + ..._buildExperiments(experiments: experiments, window: window), + ]..sort(_compareOccurrences); + + return WatchScheduleSnapshot( + generatedAt: now, + semesterStart: semesterStart, + semesterEnd: _dateAddingDays( + semesterStart, + classTable.semesterLength * DateTime.daysPerWeek, + ), + currentWeekIndex: currentWeekIndex, + validThrough: window.end, + rangeStart: window.start, + rangeEnd: window.end, + reminderMinutes: reminderMinutes, + courses: occurrences, + ); + } + + /// 天数必须为正,否则范围和有效期没有意义。 + void _validateDays(int days) { + if (days <= 0) { + throw ArgumentError.value(days, 'days', 'Must be greater than zero.'); + } + } + + /// 将请求起点归一化到本地零点,建立左闭右开的范围。 + _ScheduleWindow _makeWindow({ + required DateTime requestedStart, + required int days, + }) { + final start = _startOfDay(requestedStart); + return _ScheduleWindow( + start: start, + end: _dateAddingDays(start, days), + ); + } + + /// 展开学校课表中的重复安排。 + List _buildSchoolCourses({ + required ClassTableData classTable, + required DateTime semesterStart, + required _ScheduleWindow window, + }) { + final occurrences = []; + final arrangementsByDay = _arrangementsByWeekday(classTable.timeArrangement); + final dayCount = _calendarDayDifference(window.end, window.start); + + for (var dayOffset = 0; dayOffset < dayCount; dayOffset++) { + final date = _dateAddingDays(window.start, dayOffset); + final weekIndex = _weekIndexForDate(date, semesterStart: semesterStart); + if (!_isWeekInsideSemester(weekIndex, classTable.semesterLength)) { + continue; + } + + final arrangements = arrangementsByDay[date.weekday] ?? const []; + for (final arrangement in arrangements) { + if (!_arrangementOccurs( + arrangement, + date: date, + weekIndex: weekIndex, + )) { + continue; + } + + final interval = _schoolCourseInterval(arrangement, date: date); + if (interval == null) continue; + + final detail = classTable.getClassDetail(arrangement); + final teacher = _nonEmptyText(arrangement.teacher); + final classroom = _nonEmptyText(arrangement.classroom); + occurrences.add( + WatchCourseOccurrence( + id: _schoolCourseID( + classTable: classTable, + arrangement: arrangement, + startAt: interval.$1, + teacher: teacher, + classroom: classroom, + ), + name: detail.name, + teacher: teacher, + classroom: classroom, + startAt: interval.$1, + endAt: interval.$2, + startSection: arrangement.start, + endSection: arrangement.stop, + colorARGB: _colorAt(arrangement.index), + kind: WatchScheduleEntryKind.course, + ), + ); + } + } + return occurrences; + } + + /// 展开用户自行添加的课程时间段。 + List _buildCustomCourses({ + required ClassTableData classTable, + required List customClasses, + required _ScheduleWindow window, + }) { + final occurrences = []; + + for (var classIndex = 0; classIndex < customClasses.length; classIndex++) { + final customClass = customClasses[classIndex]; + for (final timeRange in customClass.timeRanges) { + if (!_isValidInterval( + start: timeRange.startTime, + end: timeRange.endTime, + window: window, + )) { + continue; + } + + occurrences.add( + WatchCourseOccurrence( + id: 'custom-${customClass.id}-${timeRange.id}', + name: customClass.name, + teacher: _nonEmptyText(customClass.teacher), + classroom: _nonEmptyText(customClass.classroom), + startAt: timeRange.startTime, + endAt: timeRange.endTime, + startSection: _nearestSection(timeRange.startTime, isStart: true), + endSection: _nearestSection(timeRange.endTime, isStart: false), + colorARGB: _colorAt(classTable.classDetail.length + classIndex), + kind: WatchScheduleEntryKind.course, + ), + ); + } + } + return occurrences; + } + + /// 将考试信息转换为手表日程。 + List _buildExams({ + required List subjects, + required _ScheduleWindow window, + }) { + final occurrences = []; + + for (var index = 0; index < subjects.length; index++) { + final subject = subjects[index]; + final startAt = subject.startTime; + final endAt = subject.stopTime; + if (startAt == null || + endAt == null || + !_isValidInterval(start: startAt, end: endAt, window: window)) { + continue; + } + + final classroom = _nonEmptyText(subject.place); + final seat = _nonEmptyText(subject.seat); + occurrences.add( + WatchCourseOccurrence( + id: _examID( + subject: subject, + startAt: startAt, + classroom: classroom, + seat: seat, + ), + name: '${subject.subject}${subject.type}', + teacher: null, + classroom: classroom, + startAt: startAt, + endAt: endAt, + // 考试没有明确节次,传 0 让手表按具体时间推断。 + startSection: 0, + endSection: 0, + colorARGB: _colorAt(index), + kind: WatchScheduleEntryKind.exam, + note: seat == null ? null : '座位 $seat', + ), + ); + } + return occurrences; + } + + /// 将物理实验和其他实验转换为手表日程。 + List _buildExperiments({ + required List experiments, + required _ScheduleWindow window, + }) { + final occurrences = []; + + for (var index = 0; index < experiments.length; index++) { + final experiment = experiments[index]; + for (final timeRange in experiment.timeRanges) { + final startAt = timeRange.$1; + final endAt = timeRange.$2; + if (!_isValidInterval(start: startAt, end: endAt, window: window)) { + continue; + } + + occurrences.add( + WatchCourseOccurrence( + id: _experimentID( + experiment: experiment, + index: index, + startAt: startAt, + ), + name: experiment.name, + teacher: _nonEmptyText(experiment.teacher), + classroom: _nonEmptyText(experiment.classroom), + startAt: startAt, + endAt: endAt, + // 实验时间可能不落在标准节次,交给手表按时间推断。 + startSection: 0, + endSection: 0, + colorARGB: _colorAt(index), + kind: _experimentKind(experiment.type), + note: _nonEmptyText(experiment.reference), + ), + ); + } + } + return occurrences; + } + + /// 计算某日相对学期起点的零基周次。 + int _weekIndexForDate(DateTime date, {required DateTime semesterStart}) { + final deltaDays = _calendarDayDifference(date, semesterStart); + if (deltaDays < 0) return -1; + return deltaDays ~/ DateTime.daysPerWeek; + } + + /// 判断周次是否落在学校课表定义的学期范围内。 + bool _isWeekInsideSemester(int weekIndex, int semesterLength) { + return weekIndex >= 0 && weekIndex < semesterLength; + } + + /// 判断一条学校课程安排是否在给定日期发生。 + bool _arrangementOccurs( + TimeArrangement arrangement, { + required DateTime date, + required int weekIndex, + }) { + return arrangement.day == date.weekday && + weekIndex < arrangement.weekList.length && + arrangement.weekList[weekIndex]; + } + + /// 将学校课表节次转换为具体起止时间。 + /// + /// `timeList` 中每节课包含开始和结束两个位置,因此第 n 节的开始索引为 + /// `(n - 1) * 2`,结束索引为 `(n - 1) * 2 + 1`。 + (DateTime, DateTime)? _schoolCourseInterval( + TimeArrangement arrangement, { + required DateTime date, + }) { + final startIndex = (arrangement.start - 1) * 2; + final endIndex = (arrangement.stop - 1) * 2 + 1; + if (startIndex < 0 || + endIndex >= timeList.length || + startIndex >= endIndex) { + return null; + } + + final startAt = _withMinutes(date, _periodMinutes[startIndex]); + final endAt = _withMinutes(date, _periodMinutes[endIndex]); + return endAt.isAfter(startAt) ? (startAt, endAt) : null; + } + + /// 只有起点位于快照范围内且结束晚于开始时才接受。 + bool _isValidInterval({ + required DateTime start, + required DateTime end, + required _ScheduleWindow window, + }) { + return window.contains(start) && end.isAfter(start); + } + + /// 常规课程 ID 由学期、安排字段和具体发生时间共同组成。 + /// + /// 同一课程可能因为合班、调课或数据源异常,在同一时刻出现不同教师/教室 + /// 的多条安排;这些字段必须进入 ID,否则 Watch 端按 ID 合并学期分块时 + /// 会错误覆盖其中一条。 + String _schoolCourseID({ + required ClassTableData classTable, + required TimeArrangement arrangement, + required DateTime startAt, + required String? teacher, + required String? classroom, + }) { + return '${classTable.semesterCode}-${arrangement.source.name}-' + '${arrangement.index}-${arrangement.day}-' + '${arrangement.start}-${arrangement.stop}-' + '${teacher ?? ''}-${classroom ?? ''}-' + '${startAt.millisecondsSinceEpoch}'; + } + + /// 考试 ID 加入地点和座位,避免同科目同时间的多个考场互相覆盖。 + String _examID({ + required Subject subject, + required DateTime startAt, + required String? classroom, + required String? seat, + }) { + return 'exam-${subject.subject}-${classroom ?? ''}-${seat ?? ''}-' + '${startAt.millisecondsSinceEpoch}'; + } + + /// 实验 ID 使用类型、来源索引和具体发生时间。 + String _experimentID({ + required ExperimentData experiment, + required int index, + required DateTime startAt, + }) { + return 'experiment-${experiment.type.name}-$index-' + '${startAt.millisecondsSinceEpoch}'; + } + + /// 映射实验模型类型。 + WatchScheduleEntryKind _experimentKind(ExperimentType type) { + return type == ExperimentType.physics + ? WatchScheduleEntryKind.physicsExperiment + : WatchScheduleEntryKind.otherExperiment; + } + + /// 获取与手机课程颜色序列一致的颜色,并安全处理任意索引。 + int _colorAt(int index) { + return _courseColors[index % _courseColors.length]; + } + + /// 把标准节次的分钟值合并到给定日期。 + DateTime _withMinutes(DateTime date, int minutes) { + return DateTime( + date.year, + date.month, + date.day, + minutes ~/ 60, + minutes % 60, + ); + } + + /// 根据开始或结束时间寻找距离最近的标准节次。 + int _nearestSection(DateTime dateTime, {required bool isStart}) { + final targetMinutes = _minutesSinceStartOfDay(dateTime); + var nearestSection = 1; + var nearestDistance = 1 << 30; + + for (var section = 0; section < timeList.length ~/ 2; section++) { + final index = section * 2 + (isStart ? 0 : 1); + final distance = (_periodMinutes[index] - targetMinutes).abs(); + if (distance < nearestDistance) { + nearestDistance = distance; + nearestSection = section + 1; + } + } + return nearestSection; + } + + /// 将日期时间转为当天零点后的分钟数。 + int _minutesSinceStartOfDay(DateTime dateTime) { + return dateTime.hour * 60 + dateTime.minute; + } + + /// 将 `HH:mm` 文本转为当天零点后的分钟数。 + static int _minutesFromClockText(String time) { + final parts = time.split(':'); + return int.parse(parts[0]) * 60 + int.parse(parts[1]); + } + + /// 去除首尾空白,并把空字符串统一转换为 null。 + String? _nonEmptyText(String? value) { + final normalized = value?.trim(); + return normalized == null || normalized.isEmpty ? null : normalized; + } + + /// 返回本地时区中的当天零点。 + DateTime _startOfDay(DateTime date) { + return DateTime(date.year, date.month, date.day); + } + + /// 以自然日移动和计算周次,避免夏令时的 23/25 小时日期造成漏课或错周。 + DateTime _dateAddingDays(DateTime date, int days) { + return DateTime(date.year, date.month, date.day + days); + } + + int _calendarDayDifference(DateTime date, DateTime reference) { + return DateTime.utc(date.year, date.month, date.day) + .difference(DateTime.utc(reference.year, reference.month, reference.day)) + .inDays; + } + + /// 每个日期只遍历相同星期的安排,避免重复扫描整个课表。 + Map> _arrangementsByWeekday( + List arrangements, + ) { + final result = >{}; + for (final arrangement in arrangements) { + result.putIfAbsent(arrangement.day, () => []).add(arrangement); + } + return result; + } + + /// 起止时刻相同时按稳定 ID 排序,避免输入重排触发无意义的新语义版本。 + int _compareOccurrences( + WatchCourseOccurrence left, + WatchCourseOccurrence right, + ) { + final startComparison = left.startAt.compareTo(right.startAt); + if (startComparison != 0) return startComparison; + final endComparison = left.endAt.compareTo(right.endAt); + return endComparison != 0 ? endComparison : left.id.compareTo(right.id); + } +} diff --git a/lib/repository/watch/watch_schedule_sync_service.dart b/lib/repository/watch/watch_schedule_sync_service.dart new file mode 100644 index 00000000..3dbfad41 --- /dev/null +++ b/lib/repository/watch/watch_schedule_sync_service.dart @@ -0,0 +1,329 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import 'dart:async'; +import 'dart:io'; + +import 'package:signals/signals.dart'; +import 'package:watermeter/bridge/save_to_groupid.g.dart'; +import 'package:watermeter/controller/classtable_controller.dart'; +import 'package:watermeter/controller/custom_class_controller.dart'; +import 'package:watermeter/controller/exam_controller.dart'; +import 'package:watermeter/controller/other_experiment_controller.dart'; +import 'package:watermeter/controller/physics_experiment_controller.dart'; +import 'package:watermeter/controller/theme_controller.dart'; +import 'package:watermeter/model/pda_service/custom_class.dart'; +import 'package:watermeter/model/xidian_ids/classtable.dart'; +import 'package:watermeter/model/xidian_ids/exam.dart'; +import 'package:watermeter/model/xidian_ids/experiment.dart'; +import 'package:watermeter/repository/logger.dart'; +import 'package:watermeter/repository/preference.dart' as preference; +import 'package:watermeter/repository/watch/watch_schedule_snapshot.dart'; + +/// 某一时刻从各个 Controller 读取到的完整同步输入。 +/// +/// 在 Signal effect 中一次读取同步输入并固定列表成员;源模型仍由 +/// Controller 持有,后续依赖变化通过 generation 废弃待发送任务。 +final class _WatchScheduleSourceState { + const _WatchScheduleSourceState({ + required this.classTable, + required this.effectiveTermStart, + required this.currentWeekIndex, + required this.customClasses, + required this.subjects, + required this.experiments, + required this.reminderMinutes, + }); + + final ClassTableData classTable; + final DateTime? effectiveTermStart; + final int currentWeekIndex; + final List customClasses; + final List subjects; + final List experiments; + final int reminderMinutes; +} + +/// 将手机端最新课表持续同步给配对 Apple Watch。 +/// +/// 服务只在 iOS 启动,通过 Signals 监听所有相关数据源。变化会先经过短暂 +/// 防抖,再生成完整学期快照交给原生 WatchConnectivity 层。 +class WatchScheduleSyncService { + WatchScheduleSyncService._(); + + static final WatchScheduleSyncService instance = WatchScheduleSyncService._(); + + static const _debounceDuration = Duration(milliseconds: 400); + static const _defaultReminderMinutes = 5; + + final WatchScheduleSnapshotBuilder _builder = + const WatchScheduleSnapshotBuilder(); + final WatchSyncSwiftApi _api = WatchSyncSwiftApi(); + + Timer? _debounce; + bool _started = false; + bool _suspended = false; + /// 清空课表时递增;首页刷新只可恢复它启动时所见的同一登录会话。 + int _sessionRevision = 0; + int get sessionRevision => _sessionRevision; + /// 课表、语言和清空共用此链,失败会被隔离,不会阻塞后续写入。 + Future _pendingWrite = Future.value(); + + /// 每次数据源变化都会递增;旧定时任务和旧构建任务会主动放弃发送。 + int _generation = 0; + int _languageGeneration = 0; + String? _lastSyncedLanguage; + + /// 幂等启动监听。非 iOS 平台不会创建任何 Effect 或原生通道调用。 + void start() { + if (!_shouldStart()) return; + _started = true; + if (preference.getString(preference.Preference.idsAccount).isEmpty || + preference.getString(preference.Preference.idsPassword).isEmpty) { + unawaited(clear(signedOut: true)); + } + _startReactiveSync(); + } + + /// 主动清空手机和手表端课表。 + /// + /// 递增代次并取消防抖,防止排队中的旧快照在清空后重新写回。 + Future clear({bool signedOut = false}) async { + if (!Platform.isIOS) return; + _suspended = true; + _sessionRevision += 1; + _debounce?.cancel(); + final generation = _nextGeneration(); + await _enqueueClear(generation, signedOut: signedOut); + } + + /// 数据重新就绪后恢复同步;登录前或退出前启动的旧刷新不能解除暂停。 + void resume({required int sessionRevision}) { + if (!Platform.isIOS || sessionRevision != _sessionRevision) return; + if (!_started) start(); + if (sessionRevision != _sessionRevision) return; + if (!_suspended) return; + _suspended = false; + _scheduleUpdate(_readSourceState()); + } + + /// 判断服务是否允许启动。 + bool _shouldStart() { + return !_started && Platform.isIOS; + } + + /// 创建 Signals effect;读取行为必须发生在 effect 回调内才能建立依赖。 + void _startReactiveSync() { + // 语言独立于课表和登录状态;退出后仍能同步语言,也不会为语言变化重建学期。 + effect(() { + final locale = ThemeController.i.localeIdentifierSignal.value; + final generation = ++_languageGeneration; + unawaited(_syncPreferredLanguageIfCurrent(locale, generation)); + }, options: EffectOptions(name: 'WatchPreferredLanguageSyncEffect')); + effect(() { + final state = _readSourceState(); + _scheduleUpdate(state); + }, options: EffectOptions(name: 'WatchScheduleSyncEffect')); + } + + /// 从各个 Controller 读取同一轮同步需要的全部数据。 + _WatchScheduleSourceState _readSourceState() { + final classTableController = ClassTableController.i; + final configuredMinutes = preference.getInt( + preference.Preference.courseReminderMinutesBefore, + ); + + return _WatchScheduleSourceState( + classTable: classTableController.classTableComputedSignal.value, + effectiveTermStart: classTableController.startDayComputedSignal.value, + currentWeekIndex: classTableController.currentWeekComputedSignal.value, + customClasses: List.unmodifiable( + CustomClassController.i.customClassesSignal.value, + ), + subjects: List.unmodifiable(ExamController.i.subjects.value), + experiments: List.unmodifiable([ + ...PhysicsExperimentController.i.physicsExperiments.value, + ...OtherExperimentController.i.otherExperiments.value, + ]), + reminderMinutes: _normalizedReminderMinutes(configuredMinutes), + ); + } + + /// 替换上一轮防抖任务,只保留最新数据状态。 + void _scheduleUpdate(_WatchScheduleSourceState state) { + if (_suspended) return; + _debounce?.cancel(); + final generation = _nextGeneration(); + _debounce = Timer( + _debounceDuration, + () => _runScheduledUpdate(state, generation), + ); + } + + /// 定时器到期后再次检查代次,避免已经过期的闭包继续工作。 + void _runScheduledUpdate(_WatchScheduleSourceState state, int generation) { + if (!_isCurrentGeneration(generation)) return; + unawaited(_performScheduledUpdate(state, generation)); + } + + /// 课表只响应数据变化;语言通过独立 effect 和同一写入队列同步。 + Future _performScheduledUpdate( + _WatchScheduleSourceState state, + int generation, + ) async { + if (!_isCurrentGeneration(generation)) return; + + final termStart = state.effectiveTermStart; + // 初始化和临时获取失败不具有删除语义;只在用户明确清理/退出时发送清除。 + if (termStart == null) return; + await _sync( + state: state, + effectiveTermStart: termStart, + generation: generation, + ); + } + + /// 把手机当前实际语言代码发送到原生 WatchConnectivity 层。 + Future _syncPreferredLanguageIfCurrent( + String localeIdentifier, + int generation, + ) async { + if (generation != _languageGeneration) return; + + try { + await _serializeWrite(() async { + if (generation != _languageGeneration) return false; + // 到实际执行时再去重,避免快速 A→B→A 时跳过正在发送的 B 后面的 A。 + if (localeIdentifier == _lastSyncedLanguage) return true; + final accepted = await _api.syncPreferredLanguage(localeIdentifier); + if (accepted) _lastSyncedLanguage = localeIdentifier; + return accepted; + }); + } catch (error, stackTrace) { + if (generation != _languageGeneration) return; + log.handle( + error, + stackTrace, + '[WatchScheduleSyncService] Failed to sync preferred language', + ); + } + } + + /// 构建并发送完整学期快照。 + /// + /// 构建完成后再次校验代次;如果构建期间数据源又变化,本轮结果会被丢弃。 + Future _sync({ + required _WatchScheduleSourceState state, + required DateTime effectiveTermStart, + required int generation, + }) async { + try { + final snapshot = _buildSnapshot( + state: state, + effectiveTermStart: effectiveTermStart, + ); + if (!_isCurrentGeneration(generation)) return; + + final accepted = await _sendSnapshot(snapshot, generation); + if (!_isCurrentGeneration(generation)) return; + _logSuccessfulSync(snapshot: snapshot, accepted: accepted); + } catch (error, stackTrace) { + if (!_isCurrentGeneration(generation)) return; + _logSyncFailure(error, stackTrace); + } + } + + /// 使用捕获的数据源状态创建完整学期快照。 + WatchScheduleSnapshot _buildSnapshot({ + required _WatchScheduleSourceState state, + required DateTime effectiveTermStart, + }) { + return _builder.build( + classTable: state.classTable, + effectiveTermStart: effectiveTermStart, + currentWeekIndex: state.currentWeekIndex, + now: DateTime.now(), + customClasses: state.customClasses, + subjects: state.subjects, + experiments: state.experiments, + rangeStart: effectiveTermStart, + days: state.classTable.semesterLength * DateTime.daysPerWeek, + reminderMinutes: state.reminderMinutes, + ); + } + + /// 通过 Pigeon 调用原生 Swift 层。 + Future _sendSnapshot(WatchScheduleSnapshot snapshot, int generation) { + return _serializeWrite(() async { + if (!_isCurrentGeneration(generation)) return false; + return _api.syncSchedule(WatchSchedulePayload(json: snapshot.encode())); + }); + } + + // 跨 Pigeon 通道串行写入,清理必须排在已经发出的旧同步之后。 + Future _serializeWrite(Future Function() write) { + final result = _pendingWrite.then((_) => write()); + _pendingWrite = result.then( + (_) {}, + onError: (Object _, StackTrace _) {}, + ); + return result; + } + + /// 清空一旦入队便必须执行,作为旧账户快照与后续恢复同步之间的屏障。 + /// 不能在队列实际执行时再次按 generation 跳过它,否则 resume 发出的新 + /// 快照可能沿用旧账户代次;快照和语言更新则可以直接丢弃被替代的任务。 + Future _enqueueClear( + int generation, { + required bool signedOut, + }) async { + if (!_isCurrentGeneration(generation)) return; + + try { + await _serializeWrite(() => _api.clearSchedule(signedOut)); + } catch (error, stackTrace) { + if (!_isCurrentGeneration(generation)) return; + log.handle( + error, + stackTrace, + '[WatchScheduleSyncService] Failed to clear schedule', + ); + } + } + + /// 用户配置非正数时使用默认提前提醒分钟数。 + int _normalizedReminderMinutes(int configuredMinutes) { + return configuredMinutes > 0 ? configuredMinutes : _defaultReminderMinutes; + } + + /// 生成并返回新代次。 + int _nextGeneration() { + _generation += 1; + return _generation; + } + + /// 判断异步任务是否仍代表最新数据状态。 + bool _isCurrentGeneration(int generation) { + return generation == _generation; + } + + /// 记录成功同步的数量和原生接收状态。 + void _logSuccessfulSync({ + required WatchScheduleSnapshot snapshot, + required bool accepted, + }) { + log.info( + '[WatchScheduleSyncService] Sent full semester with ' + '${snapshot.courses.length} courses; accepted=$accepted', + ); + } + + /// 统一记录构建或发送失败。 + void _logSyncFailure(Object error, StackTrace stackTrace) { + log.handle( + error, + stackTrace, + '[WatchScheduleSyncService] Failed to sync schedule', + ); + } +} diff --git a/lib/repository/widget_state_sync.dart b/lib/repository/widget_state_sync.dart index 92a17686..8b514f62 100644 --- a/lib/repository/widget_state_sync.dart +++ b/lib/repository/widget_state_sync.dart @@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart'; import 'package:watermeter/bridge/save_to_groupid.g.dart'; import 'package:watermeter/repository/logger.dart'; +import 'package:watermeter/repository/watch/watch_schedule_sync_service.dart'; import 'package:watermeter/repository/network_client.dart' show supportPath; import 'package:watermeter/repository/preference.dart' as preference; @@ -22,7 +23,8 @@ Future clearWidgetFiles() async { // Files written to the iOS App Group container by the main app. for (final fileName in [ 'ClassTable.json', - 'UserClass.json', + 'UserClass.json', // Legacy cache. + 'CustomClassesV2.json', 'ExamFile.json', 'WeekSwift.txt', 'PhysicsExperiment.json', @@ -48,7 +50,21 @@ Future clearWidgetFiles() async { /// /// - iOS: writes to the App Group container via Pigeon. /// - Android: writes to [supportPath] (same directory as widget data files). -Future syncWidgetLoginState(bool loggedIn) async { +Future syncWidgetLoginState( + bool loggedIn, { + int? expectedWatchSession, +}) async { + final service = WatchScheduleSyncService.instance; + // 退出过程中迟到的首页刷新不得恢复登录标记或重新启用旧账户同步。 + if (expectedWatchSession != null && + expectedWatchSession != service.sessionRevision) { + return; + } + if (loggedIn && expectedWatchSession != null) { + service.resume(sessionRevision: expectedWatchSession); + } else if (!loggedIn) { + await service.clear(signedOut: true); + } final state = jsonEncode({ "loggedIn": loggedIn, "updatedAt": DateTime.now().toIso8601String(), diff --git a/pigeon_bridge/save_to_groupid.dart b/pigeon_bridge/save_to_groupid.dart index 8771a769..1f5074b2 100644 --- a/pigeon_bridge/save_to_groupid.dart +++ b/pigeon_bridge/save_to_groupid.dart @@ -4,13 +4,15 @@ import 'package:pigeon/pigeon.dart'; -@ConfigurePigeon(PigeonOptions( - dartOut: 'lib/bridge/save_to_groupid.g.dart', - dartOptions: DartOptions(), - swiftOut: 'ios/Runner/SaveToGroupID.g.swift', - swiftOptions: SwiftOptions(), - copyrightHeader: "pigeon_bridge/copyright_header.txt", -)) +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/bridge/save_to_groupid.g.dart', + dartOptions: DartOptions(), + swiftOut: 'ios/Runner/SaveToGroupID.g.swift', + swiftOptions: SwiftOptions(), + copyrightHeader: "pigeon_bridge/copyright_header.txt", + ), +) class FileToGroupID { FileToGroupID({ required this.appid, @@ -22,6 +24,16 @@ class FileToGroupID { String data; } +/// Flutter 发送给 iOS 原生层的自包含课表 JSON。 +/// +/// 使用单一载荷对象而不是散落参数,后续协议增加压缩或校验字段时可以保持 +/// Host API 方法签名稳定。 +class WatchSchedulePayload { + WatchSchedulePayload({required this.json}); + + String json; +} + @HostApi() abstract class SaveToGroupIdSwiftApi { String getHostLanguage(); @@ -36,3 +48,18 @@ abstract class SaveToGroupIdSwiftApi { abstract class SaveToGroupIdFlutterApi { bool saveToGroupId(FileToGroupID data); } + +@HostApi() +abstract class WatchSyncSwiftApi { + /// 将手机 App 当前实际使用的语言同步给 Apple Watch。 + @async + bool syncPreferredLanguage(String localeIdentifier); + + /// 保存最新学期快照,并通过 WatchConnectivity 发布给 Apple Watch。 + @async + bool syncSchedule(WatchSchedulePayload payload); + + /// 清除手机端持久化课表并向手表发布空上下文。 + @async + bool clearSchedule(bool signedOut); +} diff --git a/test/signing_scripts_test.py b/test/signing_scripts_test.py new file mode 100644 index 00000000..2773a7e8 --- /dev/null +++ b/test/signing_scripts_test.py @@ -0,0 +1,96 @@ +# Copyright 2026 Traintime PDA Authors. +# SPDX-License-Identifier: MPL-2.0 + +import importlib.util +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('signing', ROOT / 'tools/signing_for_upstream.py') +signing = importlib.util.module_from_spec(spec) +spec.loader.exec_module(signing) + + +class SigningTest(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory(prefix='traintime-signing-') + self.root = Path(self.directory.name) + for name in signing.FILES: + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(signing.transformed((ROOT / name).read_text())[0]) + self.original = self.contents() + + def tearDown(self): + self.directory.cleanup() + + def contents(self): + return {name: (self.root / name).read_bytes() for name in signing.FILES if (self.root / name).is_file()} + + def test_round_trip_and_idempotence(self): + self.assertEqual(signing.switch(self.root, 'upstream', check=True), 0) + self.assertEqual(signing.switch(self.root, 'local', check=True), 1) + self.assertEqual(self.contents(), self.original) + signing.switch(self.root, 'local') + local = self.contents() + signing.switch(self.root, 'local') + self.assertEqual(self.contents(), local) + self.assertEqual(signing.switch(self.root, 'upstream', check=True), 1) + signing.switch(self.root, 'upstream') + self.assertEqual(self.contents(), self.original) + + def test_missing_file_does_not_partially_switch(self): + (self.root / signing.FILES[-1]).unlink() + before = self.contents() + with self.assertRaises(ValueError): + signing.switch(self.root, 'local') + self.assertEqual(self.contents(), before) + + def test_unmanaged_flutter_source_is_checked(self): + signing.switch(self.root, 'local') + path = self.root / 'ios/Flutter/Local.xcconfig' + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f'DEVELOPMENT_TEAM = {signing.LOCAL_TEAM_ID}') + before = self.contents() + with self.assertRaises(ValueError): + signing.switch(self.root, 'upstream') + self.assertEqual(self.contents(), before) + + def test_mismatched_group_fails_before_write(self): + path = self.root / 'watchOS/TraintimeWatch.entitlements' + path.write_text(path.read_text().replace(signing.UPSTREAM_GROUP_ID, 'group.invalid')) + before = self.contents() + with self.assertRaises(ValueError): + signing.switch(self.root, 'local') + self.assertEqual(self.contents(), before) + + def test_write_failure_rolls_back(self): + write = Path.write_bytes + counter = 0 + def fail_once(path, content): + nonlocal counter + counter += 1 + if counter == 3: + raise OSError('simulated write failure') + return write(path, content) + with patch.object(Path, 'write_bytes', fail_once): + with self.assertRaises(OSError): + signing.switch(self.root, 'local') + self.assertEqual(self.contents(), self.original) + + def test_staged_check_reads_index_even_with_local_worktree(self): + def git(*args): + return subprocess.run(['git', *args], cwd=self.root, check=True, capture_output=True) + git('init', '-q') + git('add', 'ios', 'watchOS', 'lib') + signing.switch(self.root, 'local') + self.assertEqual(signing.switch(self.root, 'upstream', staged=True), 0) + git('add', 'ios', 'watchOS', 'lib') + self.assertEqual(signing.switch(self.root, 'upstream', staged=True), 1) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/watch/interaction_regression.swift b/test/watch/interaction_regression.swift new file mode 100644 index 00000000..6ff7967f --- /dev/null +++ b/test/watch/interaction_regression.swift @@ -0,0 +1,359 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import Foundation + +/// 在 macOS 上直接编译生产状态机和缓存代码;所有持久化均使用临时 suite。 +@main +@MainActor +struct InteractionRegression { + static var assertions = 0 + + static func check(_ condition: @autoclosure () -> Bool, _ message: String) { + assertions += 1 + precondition(condition(), message) + } + + static func date(_ day: Int, hour: Int = 8) -> Date { + Calendar.current.date(from: DateComponents(year: 2026, month: 9, day: day, hour: hour))! + } + + static func ms(_ date: Date) -> Int64 { Int64(date.timeIntervalSince1970 * 1_000) } + + static func course(_ id: String, day: Int = 7, section: Int = 1) -> WatchCourse { + WatchCourse( + id: id, name: id, teacher: nil, classroom: "A-101", + startAtEpochMs: ms(date(day)), endAtEpochMs: ms(date(day, hour: 10)), + startSection: section, endSection: section + 1, + colorARGB: -1, kind: "course", note: nil) + } + + static func snapshot(_ courses: [WatchCourse], revision: Int64 = 1) -> WatchScheduleSnapshot { + var result = WatchScheduleSnapshot( + schemaVersion: 4, + generatedAtEpochMs: ms(date(1)), semesterStartEpochMs: ms(date(1, hour: 0)), + currentWeekIndex: 0, validThroughEpochMs: ms(date(30, hour: 0)), + rangeStartEpochMs: ms(date(1, hour: 0)), rangeEndEpochMs: ms(date(30, hour: 0)), + timeZoneOffsetMinutes: 480, reminderMinutes: 5, courses: courses) + result.sourceRevision = revision + return result + } + + static func main() async throws { + testPressAndCrown() + await testCompletionCancellation() + testMonthCache() + try await testDayLayoutCache() + try await testStoreCache() + try testStoreTransferRejection() + print("Passed \(assertions) interaction/lifecycle/cache regression checks") + } + + static func testPressAndCrown() { + var press = WatchPressSession() + check(press.begin(), "First touch begins a press") + check(!press.begin(), "Repeated movement does not restart the long-press timer") + press.cancel() + check(!press.begin(), "Dragging back into the button cannot revive a cancelled press") + check( + !press.finish(didTriggerLongPress: false), + "Cancelled release cannot open the mode picker") + check(press.begin(), "The next independent touch still works") + check(press.finish(didTriggerLongPress: false), "A normal short press taps once") + check(!press.finish(didTriggerLongPress: false), "Duplicate end is ignored") + check(press.begin(), "Long press begins") + check(!press.finish(didTriggerLongPress: true), "Long press does not also tap") + _ = press.begin() + press.reset() + check(!press.finish(didTriggerLongPress: false), "Leaving a page cannot complete its press") + + var crown = WatchCrownTurnSession() + check(crown.register(delta: 0, now: 0) == nil, "Focus resets are not crown input") + check(crown.register(delta: .nan, now: 0) == nil, "NaN is rejected") + check(crown.register(delta: .infinity, now: 0) == nil, "Infinite input is rejected") + check(crown.register(delta: 0.25, now: .nan) == nil, "Invalid timestamps are rejected") + check( + crown.register(delta: 0.25, now: 0)?.startsNewSession == true, + "Zero is a valid initial uptime") + check( + crown.register(delta: 0.25, now: 0.1)?.startsNewSession == false, + "Continuous rotation keeps its session") + check( + crown.register(delta: -0.25, now: 0.2)?.reversesDirection == true, + "Direction reversal is reported") + check( + crown.register(delta: -0.25, now: 0.54)?.startsNewSession == false, + "The 0.35-second threshold is preserved") + check( + crown.register(delta: -0.25, now: 0.9)?.startsNewSession == true, + "A later rotation starts a new session") + check( + crown.register(delta: 0.25, now: 0.8)?.startsNewSession == true, + "A backwards test clock cannot retain stale direction") + crown.reset() + check( + crown.register(delta: 0.25, now: 1)?.startsNewSession == true, + "Snap completion resets the session") + } + + static func testCompletionCancellation() async { + var gate = WatchInputCompletionGate() + gate.begin() + let first = gate.generation + check(gate.completeTouch(for: first), "Native idle completes a touch") + check(!gate.completeTouch(for: first), "Drag fallback cannot complete the same touch twice") + gate.begin() + check(!gate.completeTouch(for: first), "Old-step callback cannot complete a new step") + check(gate.completeTouch(for: gate.generation), "New-step touch remains valid") + + var completed: [String] = [] + let old = makeWatchAutoDismissTask(after: 0.01) { completed.append("old") } + old.cancel() + let current = makeWatchAutoDismissTask(after: 0.02) { completed.append("current") } + await old.value + await current.value + check(completed == ["current"], "Cancelled UI tasks never run after replacement") + + let crownIdle = CalendarCrownIdleCoordinator() + crownIdle.scheduleFallback { completed.append("fallback") } + crownIdle.scheduleIdleConfirmation { completed.append("idle") } + try? await Task.sleep(nanoseconds: 400_000_000) + check(completed == ["current", "idle"], "Native idle replaces rather than duplicates fallback snapping") + crownIdle.scheduleIdleConfirmation { completed.append("cancelled") } + crownIdle.cancel() + try? await Task.sleep(nanoseconds: 120_000_000) + check(completed == ["current", "idle"], "Leaving the page cancels a pending crown snap") + } + + static func testMonthCache() { + var cache = MonthCalendarCache() + let dates = (0...8).map { + Calendar.current.date(byAdding: .month, value: $0 * 3, to: date(7))! + } + for day in dates { + let window = cache.window(centeredOn: day, periodCourseIDsByDay: [:], coursesByID: [:]) + check(window.models.count == 3, "Each retained window still has three pages") + check( + window.models.values.allSatisfy { $0.cells.count == $0.rowCount * 7 }, + "Cached month grids remain complete") + } + check(!cache.isPrepared(around: dates[0]), "Unbounded browsing evicts the oldest window") + check( + dates.dropFirst().allSatisfy { cache.isPrepared(around: $0) }, + "The eight most recent windows remain cached") + _ = cache.window(centeredOn: dates[1], periodCourseIDsByDay: [:], coursesByID: [:]) + _ = cache.window(centeredOn: dates[0], periodCourseIDsByDay: [:], coursesByID: [:]) + check(cache.isPrepared(around: dates[1]), "An accessed window becomes most recent") + check(!cache.isPrepared(around: dates[2]), "LRU eviction respects refreshed access order") + cache.invalidateScheduleMarkers() + check( + !cache.isPrepared(around: dates[0]), + "Schedule replacement invalidates assembled windows") + + let sample = course("new") + let dayStart = Calendar.current.startOfDay(for: sample.startAt) + let window = cache.window( + centeredOn: sample.startAt, + periodCourseIDsByDay: [dayStart: [sample.id, nil, nil, nil, nil]], + coursesByID: [sample.id: sample]) + check( + window.periodMarkers.values.flatMap { $0.values }.contains { + $0.segmentCourses.contains { $0?.id == sample.id } + }, "Rebuilt month markers reference the new schedule") + } + + static func waitForCache(_ defaults: UserDefaults, key: String) async { + for _ in 0..<150 { + if defaults.data(forKey: key) != nil { return } + try? await Task.sleep(nanoseconds: 20_000_000) + } + } + + static func testDayLayoutCache() async throws { + let suite = "watch-layout-regression-\(UUID())" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let key = WatchPersistentCacheKey.dayCourseLayout + let tracker = DayCourseLayoutTracker(defaults: defaults) + tracker.configure(signature: "revision-1|large") + tracker.suspendPersistence() + tracker.update(metrics: .init(cardHeights: ["A": 100, "B": 150, "invalid": .nan])) + let courses = [course("A"), course("B")] + let offset = tracker.contentOffset(for: 0.5, courses: courses, spacing: 5) + check(offset == -52.5, "Crown interpolation uses measured card heights") + check( + tracker.position(forContentOffset: offset, courses: courses, spacing: 5) == 0.5, + "Touch and crown positions round-trip without jumping") + check( + tracker.contentHeight(courses: courses, spacing: 5) == 255, + "Content height includes existing spacing") + check(tracker.contentHeight(courses: [course("unmeasured")], spacing: 0) == 125, + "Unmeasured cards use the average of valid measurements") + try await Task.sleep(nanoseconds: 1_650_000_000) + check( + defaults.data(forKey: key) == nil, + "Measurements cannot restart disk writes while suspended") + tracker.resumePersistence() + await waitForCache(defaults, key: key) + check(defaults.data(forKey: key) != nil, "Idle resumes pending persistence") + let restored = DayCourseLayoutTracker(defaults: defaults) + restored.configure(signature: "revision-1|large") + check( + restored.contentHeight(courses: courses, spacing: 5) == 255, + "Matching layout signatures restore measured heights") + check(restored.contentHeight(courses: [course("unmeasured")], spacing: 0) == 125, + "Restoring measurements also restores the fallback average") + restored.configure(signature: "revision-2|accessibility") + check( + restored.contentHeight(courses: courses, spacing: 5) == 149, + "New source or font discards old heights") + check(restored.contentHeight(courses: [course("unmeasured")], spacing: 0) == 72, + "Changing layout signatures discards the cached average") + + tracker.update(metrics: .init(cardHeights: ["A": 110])) + WatchWidgetShared.clearSchedule(in: defaults) + try await Task.sleep(nanoseconds: 1_650_000_000) + check( + defaults.data(forKey: key) == nil, "A pending writer cannot resurrect a cleared cache") + tracker.configure(signature: "revision-1|large") + check( + tracker.contentHeight(courses: courses, spacing: 5) == 149, + "Clear generation invalidates memory even if signature repeats") + tracker.suspendPersistence() + restored.suspendPersistence() + } + + static func testStoreCache() async throws { + let suite = "watch-store-regression-\(UUID())" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let key = WatchPersistentCacheKey.scheduleRenderIndex + let original = snapshot([course("A"), course("C", section: 3), course("B", day: 8)]) + let store = WatchScheduleStore(defaults: defaults, sharedDefaults: nil, reloadWidgets: {}) + let originalJSON = try WatchCacheCoding.encodeJSON(original) + check( + store.replaceSchedule(json: originalJSON, scope: .semester), "Store installs valid data" + ) + await waitForCache(defaults, key: key) + let data = defaults.data(forKey: key)! + var index = try JSONSerialization.jsonObject(with: data) as! [String: Any] + check( + index["schemaVersion"] as? Int == 2, + "Render cache schema includes revision and calendar identity") + let source = index["source"] as! [String: Any] + check(source["sourceRevision"] as? Int == 1, "Monotonic source revision is persisted") + check( + source["timeZoneIdentifier"] as? String == Calendar.current.timeZone.identifier, + "Day grouping records its timezone") + + // ID 集合正确仍不代表派生索引正确:顺序和同日课时也必须匹配原始课表。 + for corruption in 0..<3 { + var malformed = index + if corruption == 0 { + malformed["sortedCourseIDs"] = ["B", "C", "A"] + } else { + var malformedDays = malformed["days"] as! [[String: Any]] + let dayIndex = malformedDays.firstIndex { + ($0["courseIDs"] as? [String])?.contains("A") == true + }! + if corruption == 1 { + malformedDays[dayIndex]["courseIDs"] = ["C", "A"] + } else { + malformedDays[dayIndex]["periodCourseIDs"] = + ["C", "A", NSNull(), NSNull(), NSNull()] as [Any] + } + malformed["days"] = malformedDays + } + defaults.set(try JSONSerialization.data(withJSONObject: malformed), forKey: key) + let restored = WatchScheduleStore( + defaults: defaults, sharedDefaults: nil, reloadWidgets: {}) + check(restored.allCourses.map(\.id) == ["A", "C", "B"], + "Restore validates the global chronological order") + check(restored.courses(on: date(7)).map(\.id) == ["A", "C"], + "Restore validates each day's order") + let restoredMonth = restored.preparedMonthCalendarWindow(centeredOn: date(7)) + check(restoredMonth.periodMarkers.values.flatMap { $0.values }.contains { + $0.segmentCourses[0]?.id == "A" && $0.segmentCourses[1]?.id == "C" + }, "Restore validates period placement even when both IDs belong to the same day") + } + + // 相同生成时间、相同 ID 与数量,只有课程节次和修订号变化。 + let changed = snapshot( + [course("A", section: 3), course("C", section: 3), course("B", day: 8)], revision: 2) + defaults.set( + try WatchCacheCoding.encodeJSON(changed), forKey: WatchWidgetShared.semesterCacheKey) + let revised = WatchScheduleStore(defaults: defaults, sharedDefaults: nil, reloadWidgets: {}) + let month = revised.preparedMonthCalendarWindow(centeredOn: date(7)) + let markers = month.periodMarkers.values.flatMap { $0.values } + check( + markers.contains { $0.segmentCourses[1]?.id == "A" }, + "A new revision rebuilds period markers despite equal generation and count") + check( + !markers.contains { $0.segmentCourses[0]?.id == "A" }, + "Old period markers are not reused") + + defaults.set( + try WatchCacheCoding.encodeJSON(original), forKey: WatchWidgetShared.semesterCacheKey) + var days = index["days"] as! [[String: Any]] + days[0]["periodCourseIDs"] = ["B", NSNull(), NSNull(), NSNull(), NSNull()] as [Any] + index["days"] = days + defaults.set(try JSONSerialization.data(withJSONObject: index), forKey: key) + let recovered = WatchScheduleStore( + defaults: defaults, sharedDefaults: nil, reloadWidgets: {}) + check( + recovered.courses(on: date(7)).map(\.id) == ["A", "C"], + "Malformed derived caches preserve original schedule data") + let repaired = recovered.preparedMonthCalendarWindow(centeredOn: date(7)) + let model = repaired.models[monthCalendarStart(for: date(7))]! + let cell = model.cells.firstIndex { + $0.map { Calendar.current.isDate($0.date, inSameDayAs: date(7)) } ?? false + }! + check( + repaired.periodMarkers[model.monthStart]?[cell]?.segmentCourses[0]?.id == "A", + "A marker cannot reference a course from another day") + + recovered.clearSchedule(signedOut: true) + check( + recovered.allCourses.isEmpty && recovered.courseListGroups.isEmpty, + "Clear removes visible and derived data together") + check(recovered.recommendedOnboardingDate == nil, "Clear removes the teaching date") + check( + recovered.presentation(at: date(7)).state == .signedOut, + "Cached overview resolution is cleared with the schedule") + check(defaults.data(forKey: key) == nil, "Clear deletes persisted render indexes") + } + + static func testStoreTransferRejection() throws { + let suite = "watch-transfer-regression-\(UUID())" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let store = WatchScheduleStore(defaults: defaults, sharedDefaults: nil, reloadWidgets: {}) + defer { store.clearSchedule(signedOut: false) } + let currentJSON = try WatchCacheCoding.encodeJSON(snapshot([course("current")], revision: 2)) + store.beginSemesterTransfer() + check(store.appendSemesterChunk(json: currentJSON, isFinal: true, scheduleVersion: "current-v2"), + "A complete semester installs its version") + + let oldJSON = try WatchCacheCoding.encodeJSON(snapshot([course("old")], revision: 1)) + check(!store.replaceSchedule(json: oldJSON, scope: .semester), + "Rejected snapshots report failure to the caller") + store.beginSemesterTransfer() + check(!store.appendSemesterChunk(json: oldJSON, isFinal: true, scheduleVersion: "old-v1"), + "An outdated final page cannot confirm an uninstalled version") + check(store.installedScheduleVersion == "current-v2" && store.allCourses.map(\.id) == ["current"], + "Both current cache and installed version survive a rejected transfer") + + let pageOne = try WatchCacheCoding.encodeJSON(snapshot([course("first")], revision: 3)) + let pageTwo = try WatchCacheCoding.encodeJSON(snapshot([course("second")], revision: 4)) + store.beginSemesterTransfer() + check(store.appendSemesterChunk(json: pageOne, isFinal: false, scheduleVersion: nil), + "The first legacy page stays in memory") + check(!store.appendSemesterChunk(json: pageTwo, isFinal: true, scheduleVersion: nil), + "Even versionless transfers reject mixed metadata") + check(store.installedScheduleVersion == "current-v2" && store.allCourses.map(\.id) == ["current"], + "Failed assembly never replaces the last complete cache") + _ = store.setPreferredLanguage("en-GB") + check(defaults.string(forKey: WatchWidgetShared.preferredLanguageKey) == "en_US", + "Language remains persistent when the shared suite is unavailable") + } +} diff --git a/test/watch/schedule_regression.swift b/test/watch/schedule_regression.swift new file mode 100644 index 00000000..f03e9126 --- /dev/null +++ b/test/watch/schedule_regression.swift @@ -0,0 +1,371 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import Foundation + +@main +struct ScheduleRegression { + static var assertions = 0 + static func check(_ condition: @autoclosure () -> Bool, _ message: String) { + assertions += 1 + guard condition() else { fatalError(message) } + } + static func date(_ day: Int = 7, _ hour: Int = 0, _ minute: Int = 0, _ second: Int = 0) -> Date + { + WatchSchedulePresentation.calendar(offsetMinutes: 480).date( + from: DateComponents( + year: 2026, month: 9, day: day, hour: hour, minute: minute, second: second))! + } + static func ms(_ value: Date) -> Int64 { Int64(value.timeIntervalSince1970 * 1000) } + static func course( + _ id: String, start: Date, end: Date, place: String = "B-302", kind: String = "course" + ) -> WatchCourse + { + .init( + id: id, name: id, teacher: nil, classroom: place, startAtEpochMs: ms(start), + endAtEpochMs: ms(end), + startSection: 1, endSection: 2, colorARGB: -1, kind: kind, note: nil) + } + static func snapshot( + _ courses: [WatchCourse], generated: Date = date(6), start: Date = date(7), + end: Date = date(28), valid: Date = date(28), term: Date = date(7) + ) -> WatchScheduleSnapshot { + .init( + schemaVersion: 4, generatedAtEpochMs: ms(generated), semesterStartEpochMs: ms(term), + currentWeekIndex: 0, + validThroughEpochMs: ms(valid), rangeStartEpochMs: ms(start), rangeEndEpochMs: ms(end), + timeZoneOffsetMinutes: 480, reminderMinutes: 5, courses: courses) + } + static func overviewRegressions() { + let first = course("A", start: date(7, 8, 30), end: date(7, 10, 5)) + let exam = course("Exam", start: date(7, 11), end: date(7, 12), kind: "exam") + let second = course("B", start: date(7, 14), end: date(7, 15, 35)) + let lab = course("Lab", start: date(7, 17), end: date(7, 18), kind: "physicsExperiment") + let tomorrow = course("Tomorrow", start: date(8, 8, 30), end: date(8, 10, 5)) + let full = WatchScheduleResolver.resolve([ + .semester: snapshot([first, exam, second, lab, tomorrow]) + ])! + func overview(_ time: Date) -> WatchOverviewSummary { + WatchOverviewSummary(WatchSchedulePresentation(resolved: full, at: time)) + } + let morning = overview(date(7, 9)) + check(morning.current?.id == first.id && morning.next?.id == exam.id, + "Overview selects the ongoing and nearest upcoming events") + check(morning.today?.remaining.courses == 2 && morning.today?.remaining.exams == 1 + && morning.today?.remaining.experiments == 1, + "Remaining events include ongoing classes and distinguish exams and labs") + check(morning.today?.additionalEndTime == lab.endAt, + "Overview supplies the final end beyond the two visible cards") + check(overview(date(7, 11)).week?.upcoming.exams == 0, + "An exam already in progress is not an upcoming exam") + let evening = overview(date(7, 18)) + check(evening.current == nil && evening.next?.id == tomorrow.id, + "After today ends only the next event is shown") + check(evening.today?.remaining.total == 0 && evening.today?.completedCount == 4, + "Today's completed summary does not switch to tomorrow") + + let twoEvents = WatchScheduleResolver.resolve([.semester: snapshot([first, second])])! + check(WatchOverviewSummary(.init(resolved: twoEvents, at: date(7, 9))) + .today?.additionalEndTime == nil, + "Do not repeat an end time already shown in a card") + let freshToday = WatchScheduleResolver.resolve([ + .semester: snapshot([first, tomorrow], valid: date(7)), + .today: snapshot([first], generated: date(7, 7), end: date(8), valid: date(8)), + ])! + let partial = WatchOverviewSummary(.init(resolved: freshToday, at: date(7, 9))) + check(partial.current?.id == first.id && partial.next == nil && partial.week == nil, + "Fresh today data cannot make stale future events or weekly totals reliable") + check(WatchOverviewSummary(.init(resolved: nil, at: date(7))).today == nil, + "Missing overview data is not a confirmed zero") + + for url in [WatchWidgetDestination.overview.url, + URL(string: "xdyou-watch://course?id=A&date=0")!, + URL(string: "xdyou-watch://day?date=0")!] { + check(WatchWidgetDestination(url: url) == .overview, + "Current and legacy widget links all open Overview") + } + check(WatchWidgetDestination(url: URL(string: "https://example.com/overview")!) == nil, + "Unrelated URLs do not change the current page") + } + + static func main() throws { + try sharedDataRegressions() + overviewRegressions() + let first = course("A", start: date(7, 8, 30), end: date(7, 10, 5)) + let second = course("B", start: date(7, 14), end: date(7, 15, 35), place: "C-101") + let tomorrow = course("C", start: date(8, 8, 30), end: date(8, 10, 5)) + let full = WatchScheduleResolver.resolve([.semester: snapshot([tomorrow, second, first])])! + func state(_ time: Date, preview: Bool = false) -> WatchSchedulePresentation { + .init(resolved: full, at: time, preview: preview) + } + check(state(date(7, 7)).state == .upcoming, "No first-class waiting tier") + check(state(date(7, 8, 14, 59)).state == .upcoming, "15-minute lower boundary") + check(state(date(7, 8, 15)).state == .imminent, "Upcoming status at 15 minutes") + for time in [date(7, 7, 30), date(7, 8, 15), date(7, 8, 29, 1), + date(7, 8, 30), date(7, 10, 4, 59)] { + check(state(time).startTimeText == "08:30", "Start time stays visible near boundaries") + check(state(time).endTimeText == "10:05", "End time stays visible near boundaries") + check(state(time).timeRangeText == "08:30–10:05", "Always display actual time range") + } + check(state(date(7, 8, 30)).state == .ongoing, "At start select current course") + check(state(date(7, 10, 4)).state == .ongoing, "Exactly one minute until dismissal") + check(state(date(7, 10, 4, 59)).state == .ongoing, "Keep clock times until actual dismissal") + check(state(date(7, 8, 29, 59)).courseProgress == nil, "No progress before class") + check(state(date(7, 8, 30)).courseProgress == 0, "Progress begins at the start of class") + check(state(date(7, 9, 17, 30)).courseProgress == 0.5, "Halfway class progress is finite") + check(state(date(7, 10, 4, 59)).courseProgress! < 1, "Progress stays bounded before end") + check(state(date(7, 10, 5)).courseProgress == nil, "Hide progress at the end of class") + check(state(date(7, 10, 5)).focus?.id == "B", "End is an exclusive boundary") + check( + state(date(7, 10, 5)).clockText(second.startAt) == "14:00", + "Gap displays next start time") + check(state(date(7, 9)).focus?.id == "A", "Split widgets keep current course") + check(state(date(7, 9), preview: true).focus?.id == "B", "Integrated preview selects next") + check(state(date(7, 9), preview: true).title == "下一节", "Next course uses a concise title") + check(state(date(7, 9), preview: true).courseProgress == nil, "Next preview has no progress") + check(state(date(7, 9), preview: true).timeRangeText == "14:00–15:35", "Preview uses next times") + check(state(date(7, 16)).state == .todayFinished, "Switch after actual final class") + check(state(date(7, 16)).dayLabel(for: tomorrow.startAt) == "明日", "Tomorrow is labeled") + check(state(date(7, 16)).summaryDate == date(7, 16), "Overview stays on today after class") + check(state(date(28)).title == "本学期结束,开心玩耍吧!", "Semester ending text") + check(state(date(9)).state == .noMoreCourses, "No more courses differs from semester end") + check(state(date(6)).state == .semesterUpcoming, "Term not started") + let late = course("Late", start: date(7, 19), end: date(7, 20, 35)) + let lateResolved = WatchScheduleResolver.resolve([.semester: snapshot([late, tomorrow])])! + check( + WatchSchedulePresentation(resolved: lateResolved, at: date(7, 20)).isCurrent, + "20:00 does not override class") + check( + WatchSchedulePresentation(resolved: lateResolved, at: date(7, 20, 35)).state + == .todayFinished, "20:35 is actual switch") + let friday = course("Friday", start: date(11, 9), end: date(11, 10)) + let sunday = course("Sunday", start: date(13, 9), end: date(13, 10)) + let monday = course("Monday", start: date(14, 9), end: date(14, 10)) + let week = WatchScheduleResolver.resolve([.semester: snapshot([friday, sunday, monday])])! + let onSunday = WatchSchedulePresentation(resolved: week, at: date(13, 8)) + check( + onSunday.weekCourses.map(\.id) == ["Friday", "Sunday"], + "Sunday belongs to Monday-start school week") + check(onSunday.weekInterval.start == date(7), "Week matrix starts on Monday") + check( + onSunday.clockText(date(13, 9)) == "09:00", + "School timezone preserved independently of host timezone") + let overnight = course("Night", start: date(7, 23, 30), end: date(8, 0, 30)) + let overnightResolved = WatchScheduleResolver.resolve([.semester: snapshot([overnight])])! + check( + WatchSchedulePresentation(resolved: overnightResolved, at: date(7, 23, 45)) + .timeRangeText == "23:30–明日 00:30", "Overnight dismissal has a day label") + check( + WatchSchedulePresentation(resolved: nil, at: date(7)).timeRangeText == nil, + "Missing data never invents class times") + check( + WatchSchedulePresentation(resolved: nil, at: date(7)).courseProgress == nil, + "Missing data never displays progress") + + let changed = course("A", start: date(7, 8, 30), end: date(7, 10, 5), place: "D-404") + let today = snapshot([changed], generated: date(7, 7), end: date(8), valid: date(8)) + let merged = WatchScheduleResolver.resolve([.semester: full.snapshot, .today: today])! + check( + merged.snapshot.courses.map(\.id) == ["A", "C"], + "New today data removes canceled B and retains other dates") + check( + merged.snapshot.courses.first?.classroom == "D-404", + "New location replaces old semester location") + let empty = snapshot([], generated: date(7, 7), end: date(8), valid: date(8)) + check( + WatchScheduleResolver.resolve([.semester: full.snapshot, .today: empty])!.snapshot + .courses.map(\.id) == ["C"], "Empty range deletes old courses") + var clockAdjusted = today + clockAdjusted.sourceRevision = ms(date(9)) + var olderRevision = full.snapshot + olderRevision.sourceRevision = ms(date(8)) + let monotonic = WatchScheduleResolver.resolve([ + .semester: olderRevision, .today: clockAdjusted, + ])! + check( + monotonic.snapshot.courses.first?.classroom == "D-404", + "State revision takes priority over adjusted generation clock") + let refreshedSemester = snapshot([], generated: date(7, 8)) + check( + WatchScheduleResolver.resolve([.semester: refreshedSemester, .today: today])!.snapshot + .courses.isEmpty, "Newest full semester is authoritative") + let newTerm = snapshot([], generated: date(8), start: date(14), term: date(14)) + check( + WatchScheduleResolver.resolve([.semester: full.snapshot, .today: newTerm])!.snapshot + .courses.isEmpty, "New semester cannot inherit old semester") + let partial = WatchScheduleResolver.resolve([.today: empty])! + check( + WatchSchedulePresentation(resolved: partial, at: date(7, 12)).state == .todayFree, + "Known empty today") + check( + !WatchSchedulePresentation(resolved: partial, at: date(7, 12)).weekIsComplete, + "Partial cache cannot report complete week") + check( + WatchSchedulePresentation(resolved: partial, at: date(8, 12)).state == .expired, + "Expired cache is explicit") + check( + WatchSchedulePresentation(resolved: nil, at: date(7)).state == .noData, + "Missing data differs from no courses") + check( + WatchSchedulePresentation(resolved: nil, at: date(7), signedOut: true).state + == .signedOut, "Signed-out state") + let gap = WatchScheduleResolver.resolve([ + .today: empty, + .fourteenDays: snapshot( + [tomorrow], generated: date(7, 8), start: date(9), end: date(10)), + ])! + check( + !gap.covers(date(7), through: date(10), at: date(7, 12)), + "Coverage gaps are not invented") + let staleBase = snapshot([first, tomorrow], valid: date(7)) + let refreshedToday = WatchScheduleResolver.resolve([.semester: staleBase, .today: today])! + check( + !refreshedToday.covers(date(8), through: date(9), at: date(7, 12)), + "Today refresh cannot extend freshness of rest of semester") + check( + refreshedToday.covers(date(7), through: date(8), at: date(7, 12)), + "Today range remains fresh") + let dates = WatchSchedulePresentation.timelineDates( + resolved: full, now: date(7, 7), previewExpiry: date(7, 9, 5)) + check(dates.first == date(7, 7), "Timeline begins now") + check(dates == Array(Set(dates)).sorted(), "Timeline sorted and deduplicated") + check(dates.contains(date(7, 8, 15)), "Timeline includes 15-minute boundary") + check(dates.contains(date(8)), "Timeline includes midnight") + check(dates.contains(date(7, 9, 5)), "Preview timeout is scheduled") + check(dates.contains(first.startAt) && dates.contains(first.endAt), "Exact class boundaries") + check(dates.contains(date(7, 8, 35)), "Refresh finite progress during class") + check(!dates.contains(date(7, 12, 5)), "Do not refresh progress between classes") + check( + !dates.contains(first.endAt.addingTimeInterval(-60 + 0.001)), + "No obsolete countdown-only boundary") + check(dates.allSatisfy { $0 <= date(9) }, "Progress timeline stays within two-day horizon") + _ = first.color // Signed ARGB values do not trap. + + check( + !WatchScheduleStateOrder.accepts( + revision: 4, generation: "old", installedRevision: 5, installedGeneration: "new", + carriesSchedule: true), "Late replies cannot restore logged-out data") + check( + !WatchScheduleStateOrder.accepts( + revision: nil, generation: nil, installedRevision: 5, installedGeneration: "new", + carriesSchedule: true), "Legacy data cannot bypass new clear") + check( + !WatchScheduleStateOrder.accepts( + revision: 5, generation: "old", installedRevision: 5, installedGeneration: "new", + carriesSchedule: true), "Equal revision cannot change account") + check( + WatchScheduleStateOrder.accepts( + revision: 6, generation: "newer", installedRevision: 5, installedGeneration: "new", + carriesSchedule: true), "New account can install newer data") + check( + WatchScheduleStateOrder.accepts( + revision: nil, generation: nil, installedRevision: 5, installedGeneration: "new", + carriesSchedule: false), "Language-only messages remain compatible") + check( + WatchScheduleStateOrder.accepts( + revision: nil, generation: nil, installedRevision: 0, installedGeneration: nil, + carriesSchedule: true), "First legacy migration remains supported") + let suite = "TraintimeWatchRegression.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + for scope in WatchScheduleScope.allCases { + defaults.set("old", forKey: WatchWidgetShared.cacheKey(for: scope)) + } + defaults.set("old", forKey: WatchPersistentCacheKey.installedSemesterVersion) + defaults.set(true, forKey: WatchPersistentCacheKey.completedOnboarding) + defaults.set("A", forKey: WatchWidgetShared.selectedCurrentCourseKey) + WatchWidgetShared.clearSchedule(in: defaults) + check( + WatchScheduleScope.allCases.allSatisfy { + defaults.object(forKey: WatchWidgetShared.cacheKey(for: $0)) == nil + }, "Clear removes all stage caches") + check( + defaults.object(forKey: WatchPersistentCacheKey.installedSemesterVersion) == nil, + "Clear removes installed version") + check( + defaults.object(forKey: WatchWidgetShared.selectedCurrentCourseKey) == nil, + "Clear removes preview") + check( + defaults.bool(forKey: WatchPersistentCacheKey.completedOnboarding), + "Clear preserves onboarding preference") + print("Passed \(assertions) schedule/cache/order regression checks") + } + + static func sharedDataRegressions() throws { + let languageCases: [(String?, WatchLanguage?)] = [ + ("zh_CN", .simplifiedChinese), ("zh-SG", .simplifiedChinese), + ("zh-Hant", .traditionalChinese), ("zh-HK", .traditionalChinese), + ("zh-MO", .traditionalChinese), ("zh-Hans-TW", .simplifiedChinese), + ("zh-Hant-CN", .traditionalChinese), (" en-GB ", .english), + ("en_US", .english), ("english", nil), ("zh-invalid-language", .simplifiedChinese), + ("fr-FR", nil), (nil, nil), + ] + for (identifier, expected) in languageCases { + check(WatchLanguage(identifier: identifier) == expected, + "Phone and Watch normalize the same language aliases: \(identifier ?? "nil")") + } + check(WatchLanguage.traditionalChinese.resourceName == "zh-Hant", + "Traditional Chinese selects the script resource rather than a region-only bundle") + check(WatchScheduleDate.calendar(offsetMinutes: Int.max).timeZone == .current, + "An invalid timezone offset cannot overflow during decoding") + check(WatchScheduleDate.epochMilliseconds( + for: WatchScheduleDate.date(fromEpochMilliseconds: 1_000_123)) == 1_000_123, + "Milliseconds round-trip through the shared conversion") + check(WatchScheduleText.compactLocation("信远 Ⅱ-105 ") == "Ⅱ-105", + "Compact location retains the exact Roman numeral and classroom number") + + check(!WatchSyncProtocol.acceptsPagination(scope: .semester, offset: 50, nextOffset: 50, hasMore: true), + "A repeated page cannot create an endless transfer") + check(!WatchSyncProtocol.acceptsPagination(scope: .today, offset: 0, nextOffset: 50, hasMore: true), + "Partial daily snapshots are not treated as a completed range") + check(WatchSyncProtocol.acceptsPagination(scope: .semester, offset: 50, nextOffset: 100, hasMore: true), + "Normal semester pagination continues") + check(WatchSyncProtocol.acceptsPagination(scope: .semester, offset: 0, nextOffset: 0, hasMore: false), + "An empty completed semester remains valid") + check(!WatchSyncProtocol.acceptsPagination(scope: .semester, offset: 50, nextOffset: 0, hasMore: false), + "A final page cannot move the semester cursor backwards") + check(WatchSyncProtocol.acceptsPagination(scope: .semester, offset: 50, nextOffset: 50, hasMore: false), + "An empty final page can confirm the current cursor") + + let early = course("A", start: date(7, 8), end: date(7, 9)) + let late = course("B", start: date(7, 10), end: date(7, 11)) + var firstPage = snapshot([late]) + firstPage.sourceRevision = 8 + firstPage.semesterEndEpochMs = ms(date(28)) + let lastPage = firstPage.replacingCourses([early]) + var transfer = WatchSemesterTransfer() + try transfer.append(firstPage) + try transfer.append(lastPage) + let completed = try transfer.completedSnapshot() + check(completed.courses.map(\.id) == ["A", "B"], "Pages are sorted only when assembled") + check(completed.sourceRevision == 8 && completed.semesterEndEpochMs == ms(date(28)), + "Assembly retains the original revision and full term boundary") + + var changedPage = lastPage + changedPage.sourceRevision = 9 + do { + try transfer.append(changedPage) + check(false, "Pages from another source revision must be rejected") + } catch WatchScheduleDataError.inconsistentSemester { + let unchanged = try transfer.completedSnapshot() + check(unchanged == completed, "Rejected pages cannot modify the accumulated data") + } + transfer.reset(keepingCapacity: true) + try transfer.append(firstPage.replacingCourses([])) + let empty = try transfer.completedSnapshot() + check(empty.courses.isEmpty, "Reset removes courses and keeps empty semesters valid") + + let json = try WatchCacheCoding.encodeJSON(firstPage) + let decoded = try WatchScheduleCoding.decode(json) + check(decoded == firstPage, "Shared decoder preserves all supported fields") + var root = try JSONSerialization.jsonObject(with: Data(json.utf8)) as! [String: Any] + root["schemaVersion"] = 999 + let unsupported = String(decoding: try JSONSerialization.data(withJSONObject: root), as: UTF8.self) + do { + _ = try WatchScheduleCoding.decode(unsupported) + check(false, "Unsupported schemas must fail before replacing a cache") + } catch WatchScheduleDataError.unsupportedSchema(let version) { + check(version == 999, "Diagnostics retain the rejected schema version") + } + } +} diff --git a/test/watch_localizations_audit_test.py b/test/watch_localizations_audit_test.py new file mode 100644 index 00000000..90dcd87c --- /dev/null +++ b/test/watch_localizations_audit_test.py @@ -0,0 +1,107 @@ +# Copyright 2026 Traintime PDA Authors. +# SPDX-License-Identifier: MPL-2.0 + +import importlib.util +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location( + 'watch_localizations_audit', ROOT / 'tools/audit_watch_localizations.py' +) +audit = importlib.util.module_from_spec(spec) +spec.loader.exec_module(audit) + + +class WatchLocalizationsAuditTest(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory(prefix='traintime-localizations-') + self.addCleanup(directory.cleanup) + self.root = Path(directory.name) + (self.root / 'watchOS').mkdir() + root_patch = patch.object(audit, 'ROOT', self.root) + root_patch.start() + self.addCleanup(root_patch.stop) + + def write_source(self, source): + (self.root / 'watchOS/Probe.swift').write_text(source, encoding='utf-8') + + def test_plain_chinese_keys_in_both_helpers(self): + self.write_source( + 'let title = watchLocalizedString("课程名称")\n' + 'let detail = watchLocalizedFormat("剩余 %d 分钟", 3)\n' + ) + self.assertEqual(audit.source_errors({'课程名称': {}, '剩余 %d 分钟': {}}), []) + + def test_swift_escapes_resolve_to_catalog_keys(self): + cases = [ + (r'选择\"XDYou\"', '选择"XDYou"'), + (r'第一行\n第二行', '第一行\n第二行'), + (r'列\t值\r末尾\0', '列\t值\r末尾\0'), + (r"单引号\'", "单引号'"), + (r'路径\\new', r'路径\new'), + (r'路径\\', '路径\\'), + (r'文字\\u{4E2D}', r'文字\u{4E2D}'), + ] + for literal, key in cases: + with self.subTest(literal=literal): + self.write_source(f'let value = watchLocalizedString("{literal}")\n') + self.assertEqual(audit.source_errors({key: {}}), []) + + def test_unicode_scalar_escapes_preserve_surrounding_chinese(self): + self.write_source(r'let value = watchLocalizedString("课程\u{1F4DA}\u{4E2D}")') + self.assertEqual(audit.source_errors({'课程📚中': {}}), []) + + def test_actual_interpolation_is_outside_static_key_audit(self): + self.write_source( + r'let title = watchLocalizedString("课程\(name)")' + '\n' + r'let detail = watchLocalizedFormat("课程\(name) %d", 3)' + '\n' + ) + self.assertEqual(audit.source_errors({}), []) + + def test_escaped_backslash_parenthesis_is_a_static_key(self): + self.write_source(r'let value = watchLocalizedString("路径\\(名称)")') + key = r'路径\(名称)' + self.assertEqual(audit.source_errors({key: {}}), []) + self.assertEqual( + audit.source_errors({}), + [f'watchOS/Probe.swift:1: missing resource {key!r}'], + ) + + def test_interpolation_depends_on_backslash_parity(self): + for count in range(1, 9): + with self.subTest(backslashes=count): + literal = '课程' + '\\' * count + '(name)' + self.write_source(f'let value = watchLocalizedString("{literal}")') + if count % 2: + self.assertEqual(audit.source_errors({}), []) + else: + key = '课程' + '\\' * (count // 2) + '(name)' + self.assertEqual( + audit.source_errors({}), + [f'watchOS/Probe.swift:1: missing resource {key!r}'], + ) + + def test_missing_resource_reports_decoded_key_path_and_line(self): + self.write_source( + 'let unrelated = 1\n' + r'let value = watchLocalizedString("第一行\n\"第二行\"")' + '\n' + ) + key = '第一行\n"第二行"' + self.assertEqual( + audit.source_errors({}), + [f'watchOS/Probe.swift:2: missing resource {key!r}'], + ) + + def test_raw_and_multiline_literals_remain_outside_scanner_coverage(self): + self.write_source( + 'let raw = watchLocalizedString(#"原始字符串"#)\n' + 'let multiline = watchLocalizedString("""\n多行字符串\n""")\n' + ) + self.assertEqual(audit.source_errors({}), []) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/watch_schedule_snapshot_test.dart b/test/watch_schedule_snapshot_test.dart new file mode 100644 index 00000000..849d25a2 --- /dev/null +++ b/test/watch_schedule_snapshot_test.dart @@ -0,0 +1,299 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import 'package:flutter_test/flutter_test.dart'; +import 'package:watermeter/model/pda_service/custom_class.dart'; +import 'package:watermeter/model/xidian_ids/classtable.dart'; +import 'package:watermeter/model/xidian_ids/exam.dart'; +import 'package:watermeter/model/xidian_ids/experiment.dart'; +import 'package:watermeter/repository/watch/watch_schedule_snapshot.dart'; + +void main() { + test('partial ranges retain the actual semester ending boundary', () { + final table = ClassTableData( + semesterLength: 20, + semesterCode: '2026-1', + termStartDay: '2026-09-07 00:00:00', + classDetail: [], + timeArrangement: [], + ); + final snapshot = const WatchScheduleSnapshotBuilder().build( + classTable: table, + effectiveTermStart: DateTime(2026, 9, 7), + currentWeekIndex: 0, + now: DateTime(2026, 9, 8), + days: 1, + ); + expect(snapshot.rangeEnd, DateTime(2026, 9, 9)); + expect( + snapshot.toJson()['semesterEndEpochMs'], + DateTime(2027, 1, 25).millisecondsSinceEpoch, + ); + expect(snapshot.courses, isEmpty); + }); + + test('expands only active course weeks into concrete watch events', () { + final classTable = ClassTableData( + semesterLength: 2, + semesterCode: '2026-1', + termStartDay: '2026-07-20 00:00:00', + classDetail: [ClassDetail(name: '计算机网络')], + timeArrangement: [ + TimeArrangement( + source: Source.school, + index: 0, + weekList: [true, false], + teacher: '张老师', + classroom: 'B-201', + day: DateTime.monday, + start: 1, + stop: 2, + ), + ], + ); + + final snapshot = const WatchScheduleSnapshotBuilder().build( + classTable: classTable, + effectiveTermStart: DateTime(2026, 7, 20), + currentWeekIndex: 0, + now: DateTime(2026, 7, 20, 7), + days: 14, + reminderMinutes: 10, + ); + + expect(snapshot.courses, hasLength(1)); + expect(snapshot.courses.single.name, '计算机网络'); + expect(snapshot.courses.single.startAt, DateTime(2026, 7, 20, 8, 30)); + expect(snapshot.courses.single.endAt, DateTime(2026, 7, 20, 10, 5)); + expect(snapshot.courses.single.startSection, 1); + expect(snapshot.courses.single.endSection, 2); + expect(snapshot.courses.single.colorARGB, 0xFFF44336); + expect(snapshot.reminderMinutes, 10); + expect(snapshot.rangeStart, DateTime(2026, 7, 20)); + expect(snapshot.rangeEnd, DateTime(2026, 8, 3)); + expect(snapshot.semesterStart, DateTime(2026, 7, 20)); + expect(snapshot.currentWeekIndex, 0); + expect(snapshot.toJson()['schemaVersion'], 4); + expect( + snapshot.toJson()['semesterStartEpochMs'], + DateTime(2026, 7, 20).millisecondsSinceEpoch, + ); + expect(snapshot.toJson()['currentWeekIndex'], 0); + }); + + test('includes user-defined courses in the rolling snapshot', () { + final classTable = ClassTableData( + semesterLength: 1, + semesterCode: '2026-1', + termStartDay: '2026-07-20 00:00:00', + ); + final customClass = CustomClass( + id: 'cc-1', + name: '自习', + classroom: '图书馆', + timeRanges: [ + CustomClassTimeRange( + id: 'tr-1', + startTime: DateTime(2026, 7, 22, 19), + endTime: DateTime(2026, 7, 22, 19, 45), + ), + ], + ); + + final snapshot = const WatchScheduleSnapshotBuilder().build( + classTable: classTable, + effectiveTermStart: DateTime(2026, 7, 20), + currentWeekIndex: 0, + now: DateTime(2026, 7, 20), + customClasses: [customClass], + days: 7, + ); + + expect(snapshot.courses.single.name, '自习'); + expect(snapshot.courses.single.classroom, '图书馆'); + expect(snapshot.courses.single.startAt, DateTime(2026, 7, 22, 19)); + expect(snapshot.courses.single.endAt, DateTime(2026, 7, 22, 19, 45)); + expect(snapshot.courses.single.startSection, 9); + expect(snapshot.courses.single.endSection, 9); + }); + + test('includes exams and experiments in the watch schedule', () { + final classTable = ClassTableData( + semesterLength: 2, + semesterCode: '2026-1', + termStartDay: '2026-07-20 00:00:00', + ); + final exam = Subject.generate( + subject: '红外物理', + typeStr: '期末考试', + time: '2026-07-21 14:00-15:35', + place: 'A-422', + seat: '8', + ); + final experiment = ExperimentData( + type: ExperimentType.physics, + name: '光学实验', + classroom: 'B-301', + timeRanges: [ + (DateTime(2026, 7, 22, 15, 55), DateTime(2026, 7, 22, 17, 30)), + ], + teacher: '张老师', + ); + + final snapshot = const WatchScheduleSnapshotBuilder().build( + classTable: classTable, + effectiveTermStart: DateTime(2026, 7, 20), + currentWeekIndex: 0, + now: DateTime(2026, 7, 20), + days: 7, + subjects: [exam], + experiments: [experiment], + ); + + expect(snapshot.courses, hasLength(2)); + expect(snapshot.courses[0].kind, WatchScheduleEntryKind.exam); + expect(snapshot.courses[0].name, '红外物理期末考试'); + expect(snapshot.courses[0].classroom, 'A-422'); + expect(snapshot.courses[0].note, '座位 8'); + expect(snapshot.courses[1].kind, WatchScheduleEntryKind.physicsExperiment); + expect(snapshot.courses[1].name, '光学实验'); + expect(snapshot.courses[1].teacher, '张老师'); + }); + + test('uses calendar-day boundaries across a daylight-saving week', () { + final classTable = ClassTableData( + semesterLength: 2, + semesterCode: '2026-1', + termStartDay: '2026-03-02 00:00:00', + classDetail: [ClassDetail(name: 'Sunday class')], + timeArrangement: [ + TimeArrangement( + source: Source.school, + index: 0, + weekList: [true, true], + teacher: '', + classroom: 'A-101', + day: DateTime.sunday, + start: 1, + stop: 2, + ), + ], + ); + final snapshot = const WatchScheduleSnapshotBuilder().build( + classTable: classTable, + effectiveTermStart: DateTime(2026, 3, 2), + currentWeekIndex: 0, + now: DateTime(2026, 3, 2), + days: 14, + ); + expect(snapshot.rangeEnd, DateTime(2026, 3, 16)); + expect(snapshot.semesterEnd, snapshot.rangeEnd); + expect(snapshot.courses.map((course) => course.startAt.day), [8, 15]); + expect(snapshot.courses.every((course) => course.startAt.hour == 8), isTrue); + }); + + test('rejects a non-positive synchronization range', () { + final classTable = ClassTableData( + semesterLength: 1, + semesterCode: '2026-1', + termStartDay: '2026-07-20 00:00:00', + ); + + expect( + () => const WatchScheduleSnapshotBuilder().build( + classTable: classTable, + effectiveTermStart: DateTime(2026, 7, 20), + currentWeekIndex: 0, + now: DateTime(2026, 7, 20), + days: 0, + ), + throwsArgumentError, + ); + }); + + test('keeps exams with the same subject and time but different seats', () { + final classTable = ClassTableData( + semesterLength: 1, + semesterCode: '2026-1', + termStartDay: '2026-07-20 00:00:00', + ); + final firstSeat = Subject.generate( + subject: '大学物理', + typeStr: '期末考试', + time: '2026-07-21 14:00-15:35', + place: 'A-422', + seat: '8', + ); + final secondSeat = Subject.generate( + subject: '大学物理', + typeStr: '期末考试', + time: '2026-07-21 14:00-15:35', + place: 'A-422', + seat: '9', + ); + + final snapshot = const WatchScheduleSnapshotBuilder().build( + classTable: classTable, + effectiveTermStart: DateTime(2026, 7, 20, 12), + currentWeekIndex: 0, + now: DateTime(2026, 7, 20, 18), + subjects: [firstSeat, secondSeat], + days: 7, + ); + + expect(snapshot.semesterStart, DateTime(2026, 7, 20)); + expect(snapshot.rangeStart, DateTime(2026, 7, 20)); + expect(snapshot.courses, hasLength(2)); + expect(snapshot.courses.map((course) => course.id).toSet(), hasLength(2)); + expect( + snapshot.courses.map((course) => course.note), + containsAll(['座位 8', '座位 9']), + ); + }); + + test('keeps overlapping school arrangements with different rooms', () { + final classTable = ClassTableData( + semesterLength: 1, + semesterCode: '2026-1', + termStartDay: '2026-07-20 00:00:00', + classDetail: [ClassDetail(name: '大学英语')], + timeArrangement: [ + TimeArrangement( + source: Source.school, + index: 0, + weekList: [true], + teacher: '张老师', + classroom: 'A-101', + day: DateTime.monday, + start: 1, + stop: 2, + ), + TimeArrangement( + source: Source.school, + index: 0, + weekList: [true], + teacher: '李老师', + classroom: 'B-202', + day: DateTime.monday, + start: 1, + stop: 2, + ), + ], + ); + + final snapshot = const WatchScheduleSnapshotBuilder().build( + classTable: classTable, + effectiveTermStart: DateTime(2026, 7, 20), + currentWeekIndex: 0, + now: DateTime(2026, 7, 20), + days: 7, + ); + + expect(snapshot.courses, hasLength(2)); + expect(snapshot.courses.map((course) => course.id).toSet(), hasLength(2)); + expect( + snapshot.courses.map((course) => course.classroom), + containsAll(['A-101', 'B-202']), + ); + }); +} diff --git a/tools/audit_watch_localizations.py b/tools/audit_watch_localizations.py new file mode 100644 index 00000000..51cc885d --- /dev/null +++ b/tools/audit_watch_localizations.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +# Copyright 2026 Traintime PDA Authors. +# SPDX-License-Identifier: MPL-2.0 + +"""Audit Watch string resources without building or launching either app.""" + +import json +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +PLACEHOLDER = re.compile(r"%(?:(\d+)\$)?(lld|ld|d|@)") +LOCALIZED_LITERAL = re.compile( + r'(?:watchLocalizedString|watchLocalizedFormat)\(\s*"(?!"")((?:\\.|[^"\\\n])*)"' +) +# An odd run of backslashes opens interpolation; escaped pairs stay literal. +SWIFT_INTERPOLATION = re.compile(r'(? str: + """Decode Swift escapes without re-decoding UTF-8 Chinese characters. + + This scanner handles ordinary, non-interpolated single-line literals. + Raw and multiline Swift strings remain outside its source coverage. + """ + def replace(match: re.Match[str]) -> str: + escape = match[1] + if escape.startswith("u{"): + return chr(int(escape[2:-1], 16)) + return SIMPLE_ESCAPES[escape] + + return SWIFT_ESCAPE.sub(replace, literal) + + +def placeholders(value: str) -> list[tuple[int, str]]: + """Compare argument positions and types, allowing reordered translations.""" + result = [] + position = 0 + for match in PLACEHOLDER.finditer(value.replace("%%", "")): + position += 1 + result.append((int(match[1]) if match[1] else position, match[2])) + return sorted(result) + + +def catalog_errors(strings: dict) -> list[str]: + errors = [] + for key, entry in strings.items(): + if entry.get("shouldTranslate") is False: + continue + for language in ("en", "zh-Hant"): + unit = entry.get("localizations", {}).get(language, {}).get("stringUnit", {}) + value = unit.get("value") + if unit.get("state") != "translated" or not value: + errors.append(f"{language}: missing translation for {key!r}") + elif placeholders(value) != placeholders(key): + errors.append(f"{language}: incompatible format arguments for {key!r}") + source_unit = entry.get("localizations", {}).get("zh-Hans", {}).get("stringUnit") + if source_unit and placeholders(source_unit.get("value", "")) != placeholders(key): + errors.append(f"zh-Hans: incompatible format arguments for {key!r}") + return errors + + +def source_errors(strings: dict) -> list[str]: + errors = [] + for path in sorted((ROOT / "watchOS").rglob("*.swift")): + source = path.read_text(encoding="utf-8") + for match in LOCALIZED_LITERAL.finditer(source): + literal = match[1] + if SWIFT_INTERPOLATION.search(literal): + continue + key = swift_literal_value(literal) + if key not in strings: + line = source.count("\n", 0, match.start()) + 1 + errors.append(f"{path.relative_to(ROOT)}:{line}: missing resource {key!r}") + return errors + + +def main() -> int: + catalog = json.loads((ROOT / "watchOS/Localizable.xcstrings").read_text(encoding="utf-8")) + strings = catalog["strings"] + errors = catalog_errors(strings) + source_errors(strings) + if catalog.get("sourceLanguage") != "zh-Hans": + errors.append("The Watch source language must remain zh-Hans") + if errors: + print("\n".join(errors)) + return 1 + count = sum(entry.get("shouldTranslate") is not False for entry in strings.values()) + print(f"Watch localization resources: {count} translatable entries; zh-Hans, zh-Hant and en complete") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/signing_for_local.py b/tools/signing_for_local.py new file mode 100644 index 00000000..38fb64e8 --- /dev/null +++ b/tools/signing_for_local.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +"""恢复本地 Apple 签名;--check 只校验,需切换时返回 1。""" + +import signing_for_upstream as signing + +REPO_ROOT = signing.REPO_ROOT +FILES = signing.FILES +REPLACEMENTS = tuple((destination, source) for source, destination in signing.REPLACEMENTS) + + +def transformed(text: str) -> tuple[str, int]: + return signing.transformed(text, target="local") + + +if __name__ == "__main__": + raise SystemExit(signing.main(target="local")) diff --git a/tools/signing_for_upstream.py b/tools/signing_for_upstream.py new file mode 100644 index 00000000..e38580ad --- /dev/null +++ b/tools/signing_for_upstream.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""切换至作者签名;--check 校验工作区,--check --staged 校验待提交内容。""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import plistlib +import re +import subprocess +import sys + +REPO_ROOT = Path(__file__).resolve().parents[1] +LOCAL_BUNDLE_ID = "com.littlestar.traintimepda" +LOCAL_TEAM_ID = "RW37L3W23K" +UPSTREAM_BUNDLE_ID = "xyz.superbart.xdyou" +UPSTREAM_GROUP_ID = f"group.{UPSTREAM_BUNDLE_ID}" +UPSTREAM_TEST_BUNDLE_ID = "io.github.benderblog.traintimePda.RunnerTests" +UPSTREAM_TEAM_ID = "YXS6PA6787" +FILES = ( + "ios/Runner.xcodeproj/project.pbxproj", + "ios/Runner/Info.plist", + "ios/Runner/Runner.entitlements", + "ios/ClasstableWidgetExtension.entitlements", + "ios/ClasstableWidget/ClasstableWidget.swift", + "lib/repository/preference.dart", + "watchOS/Shared/WatchWidgetShared.swift", + "watchOS/TraintimeWatch.entitlements", + "watchOS/Widget/TraintimeWatchWidgetExtension.entitlements", +) +REPLACEMENTS = ( + (f"{LOCAL_BUNDLE_ID}.RunnerTests", UPSTREAM_TEST_BUNDLE_ID), + (f"group.{LOCAL_BUNDLE_ID}", UPSTREAM_GROUP_ID), + (LOCAL_BUNDLE_ID, UPSTREAM_BUNDLE_ID), + (LOCAL_TEAM_ID, UPSTREAM_TEAM_ID), +) +AUDIT_ROOTS = ("ios", "watchOS", "lib") +AUDIT_SUFFIXES = {".dart", ".entitlements", ".pbxproj", ".plist", ".swift", ".xcconfig"} +# 保留 Flutter 下的源 xcconfig,只跳过依赖和生成目录。 +AUDIT_SKIP_PARTS = {".symlinks", "build", "Pods", ".ephemeral", "ephemeral"} + + +def transformed(text: str, target: str = "upstream") -> tuple[str, int]: + replacements = REPLACEMENTS if target == "upstream" else tuple((b, a) for a, b in REPLACEMENTS) + count = 0 + for source, destination in replacements: + count += text.count(source) + text = text.replace(source, destination) + return text, count + + +def read_sources(root: Path, staged: bool) -> dict[str, str]: + if staged: + listing = subprocess.run(["git", "ls-files", "-z", "--", *AUDIT_ROOTS], cwd=root, + check=True, capture_output=True).stdout.decode().split("\0") + names = [name for name in listing if name] + else: + names = [str(path.relative_to(root)) for base in AUDIT_ROOTS for path in (root / base).rglob("*") if path.is_file()] + sources = {} + for name in names: + path = Path(name) + if path.suffix not in AUDIT_SUFFIXES or any(part in AUDIT_SKIP_PARTS for part in path.parts): + continue + if staged: + sources[name] = subprocess.run(["git", "show", f":{name}"], cwd=root, check=True, + capture_output=True).stdout.decode("utf-8") + else: + sources[name] = (root / path).read_text(encoding="utf-8") + missing = [name for name in FILES if name not in sources] + if missing: + raise ValueError("找不到预期文件:" + ", ".join(missing)) + return sources + + +def validate(sources: dict[str, str], target: str) -> None: + base = UPSTREAM_BUNDLE_ID if target == "upstream" else LOCAL_BUNDLE_ID + team = UPSTREAM_TEAM_ID if target == "upstream" else LOCAL_TEAM_ID + group = f"group.{base}" + test_id = UPSTREAM_TEST_BUNDLE_ID if target == "upstream" else f"{base}.RunnerTests" + forbidden = (LOCAL_BUNDLE_ID, LOCAL_TEAM_ID) if target == "upstream" else (UPSTREAM_BUNDLE_ID, UPSTREAM_TEAM_ID, UPSTREAM_TEST_BUNDLE_ID) + for name, content in sources.items(): + if any(marker in content for marker in forbidden): + raise ValueError(f"{name} 仍含另一套签名,或出现未纳入白名单的配置") + project = sources[FILES[0]] + bundles = re.findall(r'PRODUCT_BUNDLE_IDENTIFIER\s*=\s*"?([^;"\n]+)"?;', project) + expected = {base, f"{base}.ClasstableWidget", f"{base}.watchkitapp", f"{base}.watchkitapp.widget", test_id} + if set(bundles) != expected or any(bundles.count(value) != 3 for value in expected): + raise ValueError("Xcode 各 target 的 Debug/Release/Profile Bundle ID 不一致") + for setting, value in [("DEVELOPMENT_TEAM", team), ("CUSTOM_GROUP_ID", group)]: + values = re.findall(rf'{setting}\s*=\s*"?([^;"\n]+)"?;', project) + if not values or set(values) != {value}: + raise ValueError(f"Xcode {setting} 未统一为 {value}") + if f"INFOPLIST_KEY_WKCompanionAppBundleIdentifier = {base};" not in project: + raise ValueError("Watch Companion Bundle ID 不匹配") + # 不允许误删引用或重复对象 ID;重复 ID 会把 Swift 文件当成资源处理。 + object_ids = re.findall(r'^\s*([A-F0-9]{24}) /\*.*?\*/ = ', project, re.MULTILINE) + if len(object_ids) != len(set(object_ids)): + raise ValueError("Xcode 工程存在重复对象 ID") + for name in FILES: + if name.endswith(".entitlements"): + data = plistlib.loads(sources[name].encode()) + if data.get("com.apple.security.application-groups") != [group]: + raise ValueError(f"{name} 的 App Group 不一致") + info = plistlib.loads(sources["ios/Runner/Info.plist"].encode()) + if info.get("AppGroupId") != "$(CUSTOM_GROUP_ID)": + raise ValueError("Runner AppGroupId 未引用 CUSTOM_GROUP_ID") + for name, expression in [ + ("lib/repository/preference.dart", rf"appId\s*=\s*['\"]{re.escape(group)}['\"]"), + ("watchOS/Shared/WatchWidgetShared.swift", rf'appGroupIdentifier\s*=\s*"{re.escape(group)}"'), + ("ios/ClasstableWidget/ClasstableWidget.swift", rf'widgetGroupId\s*=\s*"{re.escape(group)}"'), + ]: + if not re.search(expression, sources[name]): + raise ValueError(f"{name} 的共享容器配置不一致") + + +def switch(root: Path, target: str, *, check: bool = False, staged: bool = False) -> int: + sources = read_sources(root, staged) + proposed = dict(sources) + changes = {} + for name in FILES: + updated, count = transformed(sources[name], target) + proposed[name] = updated + if updated != sources[name]: + changes[name] = count + # 先校验所有文件及变换后的整体一致性,任何错误发生前均不写文件。 + validate(proposed, target) + if staged or check: + if changes: + print("签名尚未切换:" + ", ".join(changes)) + return 1 + print("暂存区签名检查通过。" if staged else "工作区签名检查通过。") + return 0 + originals = {name: (root / name).read_bytes() for name in changes} + written = [] + try: + for name in changes: + written.append(name) + (root / name).write_bytes(proposed[name].encode("utf-8")) + except OSError: + for name in written: + (root / name).write_bytes(originals[name]) + raise + print(f"已切换为{'作者' if target == 'upstream' else '本地'}配置,修改 {len(changes)} 个文件;未执行 git add 或 commit。") + return 0 + + +def main(target: str = "upstream") -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", help="只检查,不写入;需要切换时返回 1") + if target == "upstream": + parser.add_argument("--staged", action="store_true", help="只检查 Git 暂存区,可在本地签名状态下检查提交") + args = parser.parse_args() + try: + return switch(REPO_ROOT, target, check=args.check, staged=getattr(args, "staged", False)) + except (ValueError, OSError, subprocess.CalledProcessError) as error: + print(f"签名检查失败:{error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/test_watch_regressions.sh b/tools/test_watch_regressions.sh new file mode 100644 index 00000000..2e40465a --- /dev/null +++ b/tools/test_watch_regressions.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Copyright 2026 Traintime PDA Authors. +# SPDX-License-Identifier: MPL-2.0 +set -euo pipefail +cd "$(dirname "$0")/.." + +# 仅编译共享模型、状态机和缓存,不依赖模拟器,也不读写真实 App Group。 +watch_test_dir=$(mktemp -d "${TMPDIR:-/tmp}/traintime-watch-tests.XXXXXX") +trap 'rm -rf "$watch_test_dir"' EXIT +watch_common_sources=( + watchOS/Shared/WatchSyncSupport.swift + watchOS/Models/WatchScheduleSnapshot.swift + watchOS/Shared/WatchWidgetShared.swift + watchOS/Shared/WatchSchedulePresentation.swift +) +xcrun swiftc -parse-as-library "${watch_common_sources[@]}" \ + test/watch/schedule_regression.swift \ + -module-cache-path "$watch_test_dir/modules" -o "$watch_test_dir/schedule" +"$watch_test_dir/schedule" + +xcrun swiftc -parse-as-library "${watch_common_sources[@]}" \ + watchOS/Views/WatchInteractionSupport.swift \ + watchOS/Views/MonthCalendarData.swift \ + watchOS/Storage/WatchScheduleStore.swift \ + watchOS/Storage/DayCourseLayoutCache.swift \ + test/watch/interaction_regression.swift \ + -module-cache-path "$watch_test_dir/modules" -o "$watch_test_dir/interaction" +"$watch_test_dir/interaction" diff --git a/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Launcher-38mm@2x.png b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Launcher-38mm@2x.png new file mode 100644 index 00000000..e2acf998 Binary files /dev/null and b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Launcher-38mm@2x.png differ diff --git a/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Launcher-40mm@2x.png b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Launcher-40mm@2x.png new file mode 100644 index 00000000..dba7f412 Binary files /dev/null and b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Launcher-40mm@2x.png differ diff --git a/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Launcher-41mm@2x.png b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Launcher-41mm@2x.png new file mode 100644 index 00000000..f43ad30e Binary files /dev/null and b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Launcher-41mm@2x.png differ diff --git a/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Launcher-44mm@2x.png b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Launcher-44mm@2x.png new file mode 100644 index 00000000..5586d1dd Binary files /dev/null and b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Launcher-44mm@2x.png differ diff --git a/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Launcher-45mm@2x.png b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Launcher-45mm@2x.png new file mode 100644 index 00000000..0988cdd5 Binary files /dev/null and b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Launcher-45mm@2x.png differ diff --git a/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Launcher-49mm@2x.png b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Launcher-49mm@2x.png new file mode 100644 index 00000000..c884cded Binary files /dev/null and b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Launcher-49mm@2x.png differ diff --git a/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Marketing.png b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Marketing.png new file mode 100644 index 00000000..0948b0f5 Binary files /dev/null and b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Marketing.png differ diff --git a/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Notification-38mm@2x.png b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Notification-38mm@2x.png new file mode 100644 index 00000000..9c70d08d Binary files /dev/null and b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Notification-38mm@2x.png differ diff --git a/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Notification-42mm@2x.png b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Notification-42mm@2x.png new file mode 100644 index 00000000..055f4de6 Binary files /dev/null and b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Notification-42mm@2x.png differ diff --git a/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-QuickLook-38mm@2x.png b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-QuickLook-38mm@2x.png new file mode 100644 index 00000000..30bff29e Binary files /dev/null and b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-QuickLook-38mm@2x.png differ diff --git a/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-QuickLook-42mm@2x.png b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-QuickLook-42mm@2x.png new file mode 100644 index 00000000..019fb4cf Binary files /dev/null and b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-QuickLook-42mm@2x.png differ diff --git a/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-QuickLook-44mm@2x.png b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-QuickLook-44mm@2x.png new file mode 100644 index 00000000..659b523f Binary files /dev/null and b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-QuickLook-44mm@2x.png differ diff --git a/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-QuickLook-45mm@2x.png b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-QuickLook-45mm@2x.png new file mode 100644 index 00000000..2358676e Binary files /dev/null and b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-QuickLook-45mm@2x.png differ diff --git a/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-QuickLook-49mm@2x.png b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-QuickLook-49mm@2x.png new file mode 100644 index 00000000..79950438 Binary files /dev/null and b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-QuickLook-49mm@2x.png differ diff --git a/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Settings@2x.png b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Settings@2x.png new file mode 100644 index 00000000..81e0ed64 Binary files /dev/null and b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Settings@2x.png differ diff --git a/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Settings@3x.png b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Settings@3x.png new file mode 100644 index 00000000..f5827698 Binary files /dev/null and b/watchOS/Assets.xcassets/AppIcon.appiconset/AppIcon-Settings@3x.png differ diff --git a/watchOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/watchOS/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..cbc43280 --- /dev/null +++ b/watchOS/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,139 @@ +{ + "images" : [ + { + "filename" : "AppIcon-Notification-38mm@2x.png", + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "24x24", + "subtype" : "38mm" + }, + { + "filename" : "AppIcon-Notification-42mm@2x.png", + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "27.5x27.5", + "subtype" : "42mm" + }, + { + "filename" : "AppIcon-Settings@2x.png", + "idiom" : "watch", + "role" : "companionSettings", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "AppIcon-Settings@3x.png", + "idiom" : "watch", + "role" : "companionSettings", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "33x33", + "subtype" : "45mm" + }, + { + "filename" : "AppIcon-Launcher-38mm@2x.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "40x40", + "subtype" : "38mm" + }, + { + "filename" : "AppIcon-Launcher-40mm@2x.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "44x44", + "subtype" : "40mm" + }, + { + "filename" : "AppIcon-Launcher-41mm@2x.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "46x46", + "subtype" : "41mm" + }, + { + "filename" : "AppIcon-Launcher-44mm@2x.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "50x50", + "subtype" : "44mm" + }, + { + "filename" : "AppIcon-Launcher-45mm@2x.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "51x51", + "subtype" : "45mm" + }, + { + "filename" : "AppIcon-Launcher-49mm@2x.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "54x54", + "subtype" : "49mm" + }, + { + "filename" : "AppIcon-QuickLook-38mm@2x.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "86x86", + "subtype" : "38mm" + }, + { + "filename" : "AppIcon-QuickLook-42mm@2x.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "98x98", + "subtype" : "42mm" + }, + { + "filename" : "AppIcon-QuickLook-44mm@2x.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "108x108", + "subtype" : "44mm" + }, + { + "filename" : "AppIcon-QuickLook-45mm@2x.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "117x117", + "subtype" : "45mm" + }, + { + "filename" : "AppIcon-QuickLook-49mm@2x.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "129x129", + "subtype" : "49mm" + }, + { + "filename" : "AppIcon-Marketing.png", + "idiom" : "watch-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/watchOS/Assets.xcassets/Contents.json b/watchOS/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/watchOS/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/watchOS/Assets.xcassets/WidgetGuide/Contents.json b/watchOS/Assets.xcassets/WidgetGuide/Contents.json new file mode 100644 index 00000000..74d6a722 --- /dev/null +++ b/watchOS/Assets.xcassets/WidgetGuide/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularNameOngoing.imageset/Contents.json b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularNameOngoing.imageset/Contents.json new file mode 100644 index 00000000..7061ed41 --- /dev/null +++ b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularNameOngoing.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "WidgetGuideCircularNameOngoing.png", + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularNameOngoing.imageset/WidgetGuideCircularNameOngoing.png b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularNameOngoing.imageset/WidgetGuideCircularNameOngoing.png new file mode 100644 index 00000000..8a201897 Binary files /dev/null and b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularNameOngoing.imageset/WidgetGuideCircularNameOngoing.png differ diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularNameTomorrow.imageset/Contents.json b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularNameTomorrow.imageset/Contents.json new file mode 100644 index 00000000..d641a664 --- /dev/null +++ b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularNameTomorrow.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "WidgetGuideCircularNameTomorrow.png", + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularNameTomorrow.imageset/WidgetGuideCircularNameTomorrow.png b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularNameTomorrow.imageset/WidgetGuideCircularNameTomorrow.png new file mode 100644 index 00000000..a972e6fd Binary files /dev/null and b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularNameTomorrow.imageset/WidgetGuideCircularNameTomorrow.png differ diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularOverviewOngoing.imageset/Contents.json b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularOverviewOngoing.imageset/Contents.json new file mode 100644 index 00000000..a90fc085 --- /dev/null +++ b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularOverviewOngoing.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "WidgetGuideCircularOverviewOngoing.png", + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularOverviewOngoing.imageset/WidgetGuideCircularOverviewOngoing.png b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularOverviewOngoing.imageset/WidgetGuideCircularOverviewOngoing.png new file mode 100644 index 00000000..e1323294 Binary files /dev/null and b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularOverviewOngoing.imageset/WidgetGuideCircularOverviewOngoing.png differ diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularOverviewTomorrow.imageset/Contents.json b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularOverviewTomorrow.imageset/Contents.json new file mode 100644 index 00000000..20e09fb5 --- /dev/null +++ b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularOverviewTomorrow.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "WidgetGuideCircularOverviewTomorrow.png", + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularOverviewTomorrow.imageset/WidgetGuideCircularOverviewTomorrow.png b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularOverviewTomorrow.imageset/WidgetGuideCircularOverviewTomorrow.png new file mode 100644 index 00000000..70749ab2 Binary files /dev/null and b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularOverviewTomorrow.imageset/WidgetGuideCircularOverviewTomorrow.png differ diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularScheduleOngoing.imageset/Contents.json b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularScheduleOngoing.imageset/Contents.json new file mode 100644 index 00000000..8e984e18 --- /dev/null +++ b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularScheduleOngoing.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "WidgetGuideCircularScheduleOngoing.png", + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularScheduleOngoing.imageset/WidgetGuideCircularScheduleOngoing.png b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularScheduleOngoing.imageset/WidgetGuideCircularScheduleOngoing.png new file mode 100644 index 00000000..b594bffa Binary files /dev/null and b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularScheduleOngoing.imageset/WidgetGuideCircularScheduleOngoing.png differ diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularScheduleTomorrow.imageset/Contents.json b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularScheduleTomorrow.imageset/Contents.json new file mode 100644 index 00000000..05b97f8f --- /dev/null +++ b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularScheduleTomorrow.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "WidgetGuideCircularScheduleTomorrow.png", + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularScheduleTomorrow.imageset/WidgetGuideCircularScheduleTomorrow.png b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularScheduleTomorrow.imageset/WidgetGuideCircularScheduleTomorrow.png new file mode 100644 index 00000000..effe344a Binary files /dev/null and b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularScheduleTomorrow.imageset/WidgetGuideCircularScheduleTomorrow.png differ diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularTimeOngoing.imageset/Contents.json b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularTimeOngoing.imageset/Contents.json new file mode 100644 index 00000000..e5767e55 --- /dev/null +++ b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularTimeOngoing.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "WidgetGuideCircularTimeOngoing.png", + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularTimeOngoing.imageset/WidgetGuideCircularTimeOngoing.png b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularTimeOngoing.imageset/WidgetGuideCircularTimeOngoing.png new file mode 100644 index 00000000..78dc1841 Binary files /dev/null and b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularTimeOngoing.imageset/WidgetGuideCircularTimeOngoing.png differ diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularTimeTomorrow.imageset/Contents.json b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularTimeTomorrow.imageset/Contents.json new file mode 100644 index 00000000..dc6958bf --- /dev/null +++ b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularTimeTomorrow.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "WidgetGuideCircularTimeTomorrow.png", + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularTimeTomorrow.imageset/WidgetGuideCircularTimeTomorrow.png b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularTimeTomorrow.imageset/WidgetGuideCircularTimeTomorrow.png new file mode 100644 index 00000000..b3d9efc1 Binary files /dev/null and b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCircularTimeTomorrow.imageset/WidgetGuideCircularTimeTomorrow.png differ diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCornerScheduleOngoing.imageset/Contents.json b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCornerScheduleOngoing.imageset/Contents.json new file mode 100644 index 00000000..3705569d --- /dev/null +++ b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCornerScheduleOngoing.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "WidgetGuideCornerScheduleOngoing.png", + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCornerScheduleOngoing.imageset/WidgetGuideCornerScheduleOngoing.png b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCornerScheduleOngoing.imageset/WidgetGuideCornerScheduleOngoing.png new file mode 100644 index 00000000..c4fc2f2d Binary files /dev/null and b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideCornerScheduleOngoing.imageset/WidgetGuideCornerScheduleOngoing.png differ diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideFaceCircularOngoing.imageset/Contents.json b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideFaceCircularOngoing.imageset/Contents.json new file mode 100644 index 00000000..e322c1a0 --- /dev/null +++ b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideFaceCircularOngoing.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "WidgetGuideFaceCircularOngoing.png", + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideFaceCircularOngoing.imageset/WidgetGuideFaceCircularOngoing.png b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideFaceCircularOngoing.imageset/WidgetGuideFaceCircularOngoing.png new file mode 100644 index 00000000..925d60cf Binary files /dev/null and b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideFaceCircularOngoing.imageset/WidgetGuideFaceCircularOngoing.png differ diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideFaceRectangularOngoing.imageset/Contents.json b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideFaceRectangularOngoing.imageset/Contents.json new file mode 100644 index 00000000..6004f4e0 --- /dev/null +++ b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideFaceRectangularOngoing.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "WidgetGuideFaceRectangularOngoing.png", + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideFaceRectangularOngoing.imageset/WidgetGuideFaceRectangularOngoing.png b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideFaceRectangularOngoing.imageset/WidgetGuideFaceRectangularOngoing.png new file mode 100644 index 00000000..31597010 Binary files /dev/null and b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideFaceRectangularOngoing.imageset/WidgetGuideFaceRectangularOngoing.png differ diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularNameOngoing.imageset/Contents.json b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularNameOngoing.imageset/Contents.json new file mode 100644 index 00000000..4a3b3a02 --- /dev/null +++ b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularNameOngoing.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "WidgetGuideRectangularNameOngoing.png", + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularNameOngoing.imageset/WidgetGuideRectangularNameOngoing.png b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularNameOngoing.imageset/WidgetGuideRectangularNameOngoing.png new file mode 100644 index 00000000..d4b8ee66 Binary files /dev/null and b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularNameOngoing.imageset/WidgetGuideRectangularNameOngoing.png differ diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularNameTomorrow.imageset/Contents.json b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularNameTomorrow.imageset/Contents.json new file mode 100644 index 00000000..0e605bbf --- /dev/null +++ b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularNameTomorrow.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "WidgetGuideRectangularNameTomorrow.png", + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularNameTomorrow.imageset/WidgetGuideRectangularNameTomorrow.png b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularNameTomorrow.imageset/WidgetGuideRectangularNameTomorrow.png new file mode 100644 index 00000000..7c76889e Binary files /dev/null and b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularNameTomorrow.imageset/WidgetGuideRectangularNameTomorrow.png differ diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularOverviewOngoing.imageset/Contents.json b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularOverviewOngoing.imageset/Contents.json new file mode 100644 index 00000000..53cc287b --- /dev/null +++ b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularOverviewOngoing.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "WidgetGuideRectangularOverviewOngoing.png", + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularOverviewOngoing.imageset/WidgetGuideRectangularOverviewOngoing.png b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularOverviewOngoing.imageset/WidgetGuideRectangularOverviewOngoing.png new file mode 100644 index 00000000..ef14a815 Binary files /dev/null and b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularOverviewOngoing.imageset/WidgetGuideRectangularOverviewOngoing.png differ diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularOverviewTomorrow.imageset/Contents.json b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularOverviewTomorrow.imageset/Contents.json new file mode 100644 index 00000000..6f918fa0 --- /dev/null +++ b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularOverviewTomorrow.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "WidgetGuideRectangularOverviewTomorrow.png", + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularOverviewTomorrow.imageset/WidgetGuideRectangularOverviewTomorrow.png b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularOverviewTomorrow.imageset/WidgetGuideRectangularOverviewTomorrow.png new file mode 100644 index 00000000..d39eb833 Binary files /dev/null and b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularOverviewTomorrow.imageset/WidgetGuideRectangularOverviewTomorrow.png differ diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularScheduleOngoing.imageset/Contents.json b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularScheduleOngoing.imageset/Contents.json new file mode 100644 index 00000000..f53382ce --- /dev/null +++ b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularScheduleOngoing.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "WidgetGuideRectangularScheduleOngoing.png", + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularScheduleOngoing.imageset/WidgetGuideRectangularScheduleOngoing.png b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularScheduleOngoing.imageset/WidgetGuideRectangularScheduleOngoing.png new file mode 100644 index 00000000..3ac1a8a8 Binary files /dev/null and b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularScheduleOngoing.imageset/WidgetGuideRectangularScheduleOngoing.png differ diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularScheduleTomorrow.imageset/Contents.json b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularScheduleTomorrow.imageset/Contents.json new file mode 100644 index 00000000..839b32e5 --- /dev/null +++ b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularScheduleTomorrow.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "WidgetGuideRectangularScheduleTomorrow.png", + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularScheduleTomorrow.imageset/WidgetGuideRectangularScheduleTomorrow.png b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularScheduleTomorrow.imageset/WidgetGuideRectangularScheduleTomorrow.png new file mode 100644 index 00000000..7554d8ef Binary files /dev/null and b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularScheduleTomorrow.imageset/WidgetGuideRectangularScheduleTomorrow.png differ diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularTimeOngoing.imageset/Contents.json b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularTimeOngoing.imageset/Contents.json new file mode 100644 index 00000000..3fc44c69 --- /dev/null +++ b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularTimeOngoing.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images": [ + { + "idiom": "universal", + "scale": "1x" + }, + { + "filename": "WidgetGuideRectangularTimeOngoing.png", + "idiom": "universal", + "scale": "2x" + }, + { + "idiom": "universal", + "scale": "3x" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularTimeOngoing.imageset/WidgetGuideRectangularTimeOngoing.png b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularTimeOngoing.imageset/WidgetGuideRectangularTimeOngoing.png new file mode 100644 index 00000000..717cf201 Binary files /dev/null and b/watchOS/Assets.xcassets/WidgetGuide/WidgetGuideRectangularTimeOngoing.imageset/WidgetGuideRectangularTimeOngoing.png differ diff --git a/watchOS/Connectivity/WatchConnectivityManager.swift b/watchOS/Connectivity/WatchConnectivityManager.swift new file mode 100644 index 00000000..d46e0245 --- /dev/null +++ b/watchOS/Connectivity/WatchConnectivityManager.swift @@ -0,0 +1,874 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import Foundation +import WatchConnectivity + +/// 手机回复消息的强类型表示。 +/// +/// WatchConnectivity 使用 `[String: Any]`,先转换为该结构后,后续流程无需 +/// 重复读取字符串键,也更容易审计默认值与分页行为。 +private struct ScheduleReplyPayload { + let scope: WatchScheduleScope + let json: String + let hasMore: Bool + let nextOffset: Int + let scheduleVersion: String? + let isUnchanged: Bool +} + +/// 同步按顺序发出请求,因此只需保留当前一个请求的关联信息。 +/// 即时回复与后台回复共享此标识,先到者消费后,另一通道的迟到回复自动失效。 +private struct PendingScheduleRequest { + let id: String + let scope: WatchScheduleScope + let offset: Int +} + +/// 管理 watchOS 与配对 iPhone 之间的课表同步。 +/// +/// 同步顺序固定为:当天 → 近 14 天 → 整学期分页。当天和 14 天阶段完整 +/// 成功后只替换其日期范围,整学期分页全部完成后再整体替换。 +final class WatchConnectivityManager: NSObject, WCSessionDelegate { + static let shared = WatchConnectivityManager() + + private typealias Key = WatchSyncProtocol.Key + private typealias MessageType = WatchSyncProtocol.MessageType + + /// 一整轮渐进刷新允许的最长等待时间。 + private static let refreshTimeoutNanoseconds: UInt64 = + 12_000_000_000 + + /// App 打开后等待手机当轮首次新回复的时间。 + private static let launchReplyTimeoutNanoseconds: UInt64 = + 3_000_000_000 + + /// Store 由 App 生命周期持有,此处使用弱引用避免单例形成所有权环。 + private weak var store: WatchScheduleStore? + + /// 每轮刷新使用独立 ID,忽略上一轮迟到的回复和超时任务。 + private var refreshID = UUID() + + /// 当前三阶段响应必须全部属于同一个手机课表版本。 + private var activeIncomingScheduleVersion: String? + private var activeIncomingAccountGeneration: String? + private var pendingRequest: PendingScheduleRequest? + /// 超时失败会清空 Store 的学期缓冲;晚到的后半页只能触发重新同步。 + private var semesterBufferWasDiscarded = false + private var refreshTimeoutTask: Task? + private var launchReplyTimeoutTask: Task? + + /// 每次 App 打开时建立一次独立的手机回复等待窗口。 + /// + /// 它不能复用普通刷新超时:手机不可达时普通刷新会立即回退到系统保存的 + /// Application Context,而启动提示需要继续等待当轮手机新回复。 + private var launchAttemptID: UUID? + private var launchAttemptRefreshID: UUID? + + private override init() { + super.init() + } + + /// 绑定 Store、激活 WCSession,并立即消费系统保存的最近上下文。 + @MainActor + func activate(store: WatchScheduleStore) { + self.store = store + guard WCSession.isSupported() else { + store.failRefresh( + watchLocalizedString("此设备不支持与 iPhone 同步") + ) + return + } + + let session = WCSession.default + configureAndActivate(session) + consumeLatestApplicationContext(from: session) + beginLaunchRefresh() + } + + /// 启动或强制重启三阶段渐进刷新。 + @MainActor + func beginProgressiveRefresh(force: Bool = false) { + _ = startProgressiveRefresh(force: force) + } + + /// App 每次打开或回到前台时调用:发起课表请求,并单独等待当轮回复。 + /// + /// Application Context 仍可立即恢复缓存,但旧上下文不算本轮手机回复; + /// 即时消息、新 Application Context 或后台队列回复都能取消提示。 + @MainActor + func beginLaunchRefresh() { + guard let store else { return } + + let attemptID = UUID() + launchAttemptID = attemptID + launchAttemptRefreshID = nil + store.beginLaunchSyncAttempt() + scheduleLaunchReplyTimeout(for: attemptID) + + let session = WCSession.default + guard session.activationState == .activated else { + if session.activationState == .notActivated { + configureAndActivate(session) + } + return + } + startProgressiveRefreshForLaunchAttempt() + } + + /// 创建一轮渐进刷新并返回其 ID,供启动等待与具体请求精确关联。 + @MainActor + @discardableResult + private func startProgressiveRefresh(force: Bool) -> UUID? { + guard let store else { return nil } + guard force || !store.isRefreshing else { return nil } + + let newRefreshID = UUID() + refreshID = newRefreshID + resetIncomingTransfer() + store.beginRefresh() + request( + scope: .today, + offset: 0, + refreshID: newRefreshID + ) + scheduleTimeout(for: newRefreshID) + return newRefreshID + } + + /// WCSession 激活后为当前启动等待窗口发送唯一一轮渐进请求。 + @MainActor + private func startProgressiveRefreshForLaunchAttempt() { + guard launchAttemptID != nil, + launchAttemptRefreshID == nil + else { + return + } + launchAttemptRefreshID = startProgressiveRefresh(force: true) + } + + /// 设置 WCSession 代理并触发系统激活。 + private func configureAndActivate(_ session: WCSession) { + session.delegate = self + session.activate() + } + + /// 12 秒内没有完成刷新时,优先尝试系统缓存的 Application Context。 + /// + /// 超时任务携带刷新 ID;新一轮刷新启动后,旧任务即使醒来也不会改状态。 + @MainActor + private func scheduleTimeout(for expectedRefreshID: UUID) { + refreshTimeoutTask?.cancel() + refreshTimeoutTask = Task { @MainActor [weak self] in + try? await Task.sleep( + nanoseconds: Self.refreshTimeoutNanoseconds + ) + guard !Task.isCancelled, + let self, + self.isActiveRefresh(expectedRefreshID), + self.store?.isRefreshing == true + else { + return + } + + let restoredContext = self.consumeLatestApplicationContext( + from: WCSession.default + ) + guard self.isActiveRefresh(expectedRefreshID) else { return } + if restoredContext { + self.store?.finishRefresh() + } else { + self.semesterBufferWasDiscarded = + self.pendingRequest?.scope == .semester + && (self.pendingRequest?.offset ?? 0) > 0 + self.store?.failRefresh( + watchLocalizedString("暂时无法连接 iPhone") + ) + } + self.refreshTimeoutTask = nil + } + } + + /// 启动请求超时只改变提示状态,不触碰任何已安装或正在展示的缓存。 + @MainActor + private func scheduleLaunchReplyTimeout(for expectedAttemptID: UUID) { + launchReplyTimeoutTask?.cancel() + launchReplyTimeoutTask = Task { @MainActor [weak self] in + try? await Task.sleep( + nanoseconds: Self.launchReplyTimeoutNanoseconds + ) + guard !Task.isCancelled, + let self, + self.launchAttemptID == expectedAttemptID + else { + return + } + self.store?.markLaunchSyncTimedOut() + self.launchReplyTimeoutTask = nil + } + } + + /// WCSession 激活完成回调。 + func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + if let error { + reportActivationFailure(error) + return + } + guard activationState == .activated else { return } + handleActivatedSession(session) + } + + /// 手机推送新的 Application Context 时立即尝试安装。 + func session( + _ session: WCSession, + didReceiveApplicationContext applicationContext: [String: Any] + ) { + Task { @MainActor [weak self] in + guard let self else { return } + self.receiveCurrentPhoneReply() + let requiresFullSync = + self.applicationContextRequiresFullSync(applicationContext) + let accepted = self.consumeApplicationContext(applicationContext) + if accepted && requiresFullSync { + self.beginProgressiveRefresh() + } + } + } + + /// 手机语言切换时会在 Application Context 之外补发实时消息。 + func session( + _ session: WCSession, + didReceiveMessage message: [String: Any] + ) { + Task { @MainActor [weak self] in + if message[Key.scheduleCleared] as? Bool == true { + _ = self?.consumeApplicationContext(message) + } else { + self?.consumePreferredLanguage(from: message) + } + } + } + + /// 接收 iPhone 通过 `transferUserInfo` 返回的后台队列回复。 + /// + /// 队列可能在即时消息失败较久后才送达,因此必须同时校验整轮 refreshID + /// 和单次 requestID;旧刷新、重复分页均不会污染当前缓存。 + func session( + _ session: WCSession, + didReceiveUserInfo userInfo: [String: Any] + ) { + guard userInfo[Key.messageType] as? String == MessageType.response, + let refreshIDString = userInfo[Key.refreshID] as? String, + let queuedRefreshID = UUID(uuidString: refreshIDString), + let requestID = userInfo[Key.requestID] as? String, + !requestID.isEmpty + else { + return + } + + Task { @MainActor [weak self] in + self?.handle( + reply: userInfo, + requestID: requestID, + refreshID: queuedRefreshID + ) + } + } + + /// 超时提示出现后用户再打开手机时,系统会更新可达状态。 + /// + /// 只要当前仍有一轮启动等待尚未收到回复,就重建请求与超时窗口;手机 + /// 成功回复后 `receiveCurrentPhoneReply` 会自动撤下缓存提示或整页引导。 + func sessionReachabilityDidChange(_ session: WCSession) { + guard session.isReachable else { return } + Task { @MainActor [weak self] in + guard let self, + self.launchAttemptID != nil + else { + return + } + self.beginLaunchRefresh() + } + } + + /// 把激活错误切回主线程交给 Store 展示。 + private func reportActivationFailure(_ error: Error) { + Task { @MainActor [weak self] in + self?.store?.failRefresh( + watchLocalizedFormat( + "无法连接 iPhone:%@", + error.localizedDescription + ) + ) + } + } + + /// 激活成功后先消费系统上下文,再开始一轮实时刷新。 + /// + /// 这里必须使用 `force`:用户可能在 Session 尚未激活时已经点了刷新, + /// 此时 Store 已处于刷新状态;普通启动会被“正在刷新”的幂等保护拦下, + /// 导致真正的请求永远没有发送,只能等到超时。 + private func handleActivatedSession(_ session: WCSession) { + Task { @MainActor [weak self] in + guard let self else { return } + self.consumeLatestApplicationContext(from: session) + if self.launchAttemptID == nil { + self.beginLaunchRefresh() + } else { + self.startProgressiveRefreshForLaunchAttempt() + } + } + } + + /// 向手机请求指定范围和分页位置。 + @MainActor + private func request( + scope: WatchScheduleScope, + offset: Int, + refreshID expectedRefreshID: UUID + ) { + guard isActiveRefresh(expectedRefreshID), + store != nil + else { + return + } + + let session = WCSession.default + guard session.activationState == .activated else { + session.activate() + return + } + + let requestID = UUID().uuidString + pendingRequest = PendingScheduleRequest( + id: requestID, + scope: scope, + offset: offset + ) + sendRequest( + through: session, + scope: scope, + offset: offset, + installedScheduleVersion: store?.installedScheduleVersion, + requestID: requestID, + refreshID: expectedRefreshID + ) + } + + /// 构造协议消息,所有字段在单一函数中维护。 + private func requestMessage( + scope: WatchScheduleScope, + offset: Int, + installedScheduleVersion: String?, + requestID: String, + refreshID: UUID + ) -> [String: Any] { + var message: [String: Any] = [ + Key.requestSchedule: true, + Key.scope: scope.rawValue, + Key.offset: offset, + Key.messageType: MessageType.request, + Key.refreshID: refreshID.uuidString, + Key.requestID: requestID, + ] + if let installedScheduleVersion { + message[Key.scheduleVersion] = installedScheduleVersion + } + message[Key.accountGeneration] = WatchWidgetShared.defaults?.string(forKey: WatchWidgetShared.accountGenerationKey) + return message + } + + /// 实际发送消息并把闭包结果重新调度到主线程。 + private func sendRequest( + through session: WCSession, + scope: WatchScheduleScope, + offset: Int, + installedScheduleVersion: String?, + requestID: String, + refreshID expectedRefreshID: UUID + ) { + session.sendMessage( + requestMessage( + scope: scope, + offset: offset, + installedScheduleVersion: installedScheduleVersion, + requestID: requestID, + refreshID: expectedRefreshID + ), + replyHandler: { [weak self] reply in + Task { @MainActor in + self?.handle( + reply: reply, + requestID: requestID, + refreshID: expectedRefreshID + ) + } + }, + errorHandler: { [weak self] error in + Task { @MainActor in + self?.handleSendFailure( + error, + session: session, + scope: scope, + offset: offset, + installedScheduleVersion: installedScheduleVersion, + requestID: requestID, + refreshID: expectedRefreshID + ) + } + } + ) + } + + /// 即时消息失败后排队后台请求,并继续展示已有 Application Context。 + /// + /// 实体设备上不立即结束刷新:系统稍后送达 UserInfo 后仍会继续当天、 + /// 14 天和整学期三个阶段。模拟器不建立 UserInfo 队列,只使用 + /// Application Context 作为失败兜底。 + @MainActor + private func handleSendFailure( + _ error: Error, + session: WCSession, + scope: WatchScheduleScope, + offset: Int, + installedScheduleVersion: String?, + requestID: String, + refreshID expectedRefreshID: UUID + ) { + guard isPendingRequest(requestID, refreshID: expectedRefreshID) else { return } + + let queued = queueRequest( + through: session, + scope: scope, + offset: offset, + installedScheduleVersion: installedScheduleVersion, + requestID: requestID, + refreshID: expectedRefreshID + ) + let restoredContext = consumeLatestApplicationContext(from: session) + guard isActiveRefresh(expectedRefreshID) else { return } + guard !queued else { return } + + pendingRequest = nil + if restoredContext { + store?.finishRefresh() + } else { + store?.failRefresh( + watchLocalizedFormat( + "同步失败:%@", + error.localizedDescription + ) + ) + } + cancelRefreshTimeout() + } + + /// 将即时发送失败的同一请求交给 WatchConnectivity 后台可靠队列。 + @MainActor + private func queueRequest( + through session: WCSession, + scope: WatchScheduleScope, + offset: Int, + installedScheduleVersion: String?, + requestID: String, + refreshID expectedRefreshID: UUID + ) -> Bool { +#if targetEnvironment(simulator) + return false +#else + guard session.activationState == .activated else { return false } + session.transferUserInfo( + requestMessage( + scope: scope, + offset: offset, + installedScheduleVersion: installedScheduleVersion, + requestID: requestID, + refreshID: expectedRefreshID + ) + ) + return true +#endif + } + + /// 解析并处理手机回复。 + @MainActor + private func handle( + reply: [String: Any], + requestID: String, + refreshID expectedRefreshID: UUID + ) { + guard isPendingRequest(requestID, refreshID: expectedRefreshID), + let request = pendingRequest, + let store + else { + return + } + // 先认领回复再解析正文;后续阶段即使已发出,也不会再次消费本页。 + pendingRequest = nil + receiveCurrentPhoneReply(refreshID: expectedRefreshID) + defer { + if isActiveRefresh(expectedRefreshID), !store.isRefreshing { + cancelRefreshTimeout() + } + } + + guard consumeResponseMetadata(reply) else { return } + if consumeClearIfNeeded(reply) { return } + guard !semesterBufferWasDiscarded else { + beginProgressiveRefresh(force: true) + return + } + + let payload = parseReply( + reply, + fallbackScope: request.scope + ) + + guard payload.scope == request.scope else { + store.failRefresh(watchLocalizedString("课表分页数据无效,请重新刷新")) + return + } + + // 版本一致时手机不会附带 JSON;直接结束刷新,现有本地课表不变。 + guard !finishUnchangedRefreshIfNeeded(payload, store: store) else { + return + } + + // 三阶段中途若手机课表再次变化,丢弃旧学期分页并从“当天”重启, + // 防止把两个版本的课程拼成一份整学期缓存。 + guard acceptIncomingScheduleVersion( + payload.scheduleVersion, + accountGeneration: reply[Key.accountGeneration] as? String + ) else { + beginProgressiveRefresh(force: true) + return + } + + guard WatchSyncProtocol.acceptsPagination( + scope: payload.scope, + offset: request.offset, + nextOffset: payload.nextOffset, + hasMore: payload.hasMore + ) + else { + store.failRefresh(watchLocalizedString("课表分页数据无效,请重新刷新")) + return + } + + if payload.scope == .semester { + handleSemesterReply( + payload, + refreshID: expectedRefreshID + ) + return + } + + guard installCompletedRange(payload, into: store) else { return } + + continueAfterCompletedScope( + payload.scope, + refreshID: expectedRefreshID + ) + } + + /// 处理手机返回的“版本未变化”轻量确认。 + /// + /// 该回复没有 `scheduleJSON`。这里不能尝试走普通解码流程,也不能清空 + /// 当前页面;只结束刷新状态即可继续使用手表已经完整安装的学期缓存。 + @MainActor + private func finishUnchangedRefreshIfNeeded( + _ payload: ScheduleReplyPayload, + store: WatchScheduleStore + ) -> Bool { + guard payload.isUnchanged else { return false } + guard let version = WatchScheduleText.nonempty(payload.scheduleVersion), + version == store.installedScheduleVersion + else { + // 账户切换或缓存被清除后,轻量确认已不能证明本地仍有完整课表。 + beginProgressiveRefresh(force: true) + return true + } + resetIncomingTransfer() + store.finishRefreshWithoutScheduleChanges() + return true + } + + /// 安装当天或近 14 天阶段,并集中处理无效载荷错误。 + /// + /// 学期分页由 `handleSemesterReply` 负责,不会进入此函数。Store 会只替换 + /// 当前阶段覆盖的日期范围,因此当天阶段不会误删其他日期,14 天阶段也 + /// 不会提前覆盖尚未完成的整学期缓存。 + @MainActor + private func installCompletedRange( + _ payload: ScheduleReplyPayload, + into store: WatchScheduleStore + ) -> Bool { + guard store.replaceSchedule( + json: payload.json, + scope: payload.scope + ) else { + store.failRefresh( + watchLocalizedString("手机端暂无课表,请先刷新手机课表") + ) + return false + } + return true + } + + /// 把弱类型字典解析成稳定结构;缺失 scope 时使用请求中的预期范围。 + private func parseReply( + _ reply: [String: Any], + fallbackScope: WatchScheduleScope + ) -> ScheduleReplyPayload { + let scope = WatchScheduleScope( + rawValue: reply[Key.scope] as? String ?? "" + ) ?? fallbackScope + + return ScheduleReplyPayload( + scope: scope, + json: reply[Key.scheduleJSON] as? String ?? "", + hasMore: reply[Key.hasMore] as? Bool ?? false, + nextOffset: reply[Key.nextOffset] as? Int ?? 0, + scheduleVersion: reply[Key.scheduleVersion] as? String, + isUnchanged: reply[Key.scheduleUnchanged] as? Bool ?? false + ) + } + + /// 版本与账户共同标识本轮数据,避免两账户课程内容相同时拼接不同分页。 + @MainActor + private func acceptIncomingScheduleVersion( + _ value: String?, + accountGeneration: String? + ) -> Bool { + if let activeIncomingAccountGeneration, + activeIncomingAccountGeneration != accountGeneration { + return false + } + activeIncomingAccountGeneration = accountGeneration + guard let value, !value.isEmpty else { + // 与尚未升级协议的手机保持兼容。 + return activeIncomingScheduleVersion == nil + } + guard let activeIncomingScheduleVersion else { + self.activeIncomingScheduleVersion = value + return true + } + return activeIncomingScheduleVersion == value + } + + /// 合并学期分页;仍有下一页时继续请求,否则 Store 会结束整轮刷新。 + @MainActor + private func handleSemesterReply( + _ payload: ScheduleReplyPayload, + refreshID expectedRefreshID: UUID + ) { + guard let store else { return } + let accepted = store.appendSemesterChunk( + json: payload.json, + isFinal: !payload.hasMore, + scheduleVersion: payload.scheduleVersion + ) + guard accepted else { return } + guard payload.hasMore else { + resetIncomingTransfer() + return + } + + request( + scope: .semester, + offset: payload.nextOffset, + refreshID: expectedRefreshID + ) + } + + /// 一个非学期阶段成功后进入下一阶段。 + @MainActor + private func continueAfterCompletedScope( + _ scope: WatchScheduleScope, + refreshID expectedRefreshID: UUID + ) { + guard let store else { return } + guard let nextScope = scope.next else { + resetIncomingTransfer() + store.finishRefresh() + return + } + + prepareStore(for: nextScope) + request( + scope: nextScope, + offset: 0, + refreshID: expectedRefreshID + ) + } + + /// 在发起下一阶段前更新页面上的刷新状态。 + @MainActor + private func prepareStore(for scope: WatchScheduleScope) { + if scope == .semester { + store?.beginSemesterTransfer() + } else { + store?.setLoadingScope(scope) + } + } + + /// 消费 WCSession 当前持有的最近 Application Context。 + @MainActor + @discardableResult + private func consumeLatestApplicationContext( + from session: WCSession + ) -> Bool { + consumeApplicationContext(session.receivedApplicationContext) + } + + /// 从 Application Context 中读取完整快照并交给 Store 校验。 + @MainActor + @discardableResult + private func consumeApplicationContext( + _ context: [String: Any] + ) -> Bool { + guard consumeResponseMetadata(context) else { return false } + if consumeClearIfNeeded(context) { return true } + + // 相同的完整版本直接复用本地缓存,避免重复解码和页面重新分组。 + if let version = context[Key.scheduleVersion] as? String, + version == store?.installedScheduleVersion + { + return true + } + + guard let json = context[Key.scheduleJSON] as? String, + !json.isEmpty + else { + return false + } + + let scope = WatchScheduleScope( + rawValue: context[Key.scope] as? String ?? "" + ) ?? .fourteenDays + return store?.replaceSchedule(json: json, scope: scope) ?? false + } + + /// 两条课表接收路径先校验状态再安装语言;清空回复也不会遗漏语言更新。 + @MainActor + private func consumeResponseMetadata(_ payload: [String: Any]) -> Bool { + guard acceptStateEnvelope(payload) else { return false } + consumePreferredLanguage(from: payload) + return true + } + + /// 修订号跨 iPhone 重启持久化;退出后的旧上下文和旧分页均不能复活课表。 + @MainActor + private func acceptStateEnvelope(_ payload: [String: Any]) -> Bool { + let defaults = WatchWidgetShared.defaults ?? .standard + let installed = defaults.integer(forKey: WatchWidgetShared.stateRevisionKey) + let revision = payload[Key.stateRevision] as? Int + guard WatchScheduleStateOrder.accepts( + revision: revision, generation: payload[Key.accountGeneration] as? String, + installedRevision: installed, + installedGeneration: defaults.string(forKey: WatchWidgetShared.accountGenerationKey), + carriesSchedule: payload[Key.scheduleJSON] != nil || payload[Key.scheduleUnchanged] != nil || payload[Key.scheduleCleared] != nil + ) else { return false } + guard let revision else { return true } + if let generation = payload[Key.accountGeneration] as? String { + let previous = defaults.string(forKey: WatchWidgetShared.accountGenerationKey) + if revision == installed, let previous, previous != generation { return false } + if previous != nil && previous != generation { + // 保留本轮已接收的版本/账户标识,令中途换账户的分页从当天重启。 + store?.clearSchedule(signedOut: false) + } + defaults.set(generation, forKey: WatchWidgetShared.accountGenerationKey) + } + defaults.set(revision, forKey: WatchWidgetShared.stateRevisionKey) + return true + } + + /// 清空同时废弃请求和计时窗口,确保退出前的队列回复不能恢复课表。 + @MainActor + private func consumeClearIfNeeded(_ payload: [String: Any]) -> Bool { + guard payload[Key.scheduleCleared] as? Bool == true else { return false } + refreshID = UUID() + resetIncomingTransfer() + cancelRefreshTimeout() + launchReplyTimeoutTask?.cancel() + launchReplyTimeoutTask = nil + launchAttemptID = nil + launchAttemptRefreshID = nil + for transfer in WCSession.default.outstandingUserInfoTransfers { transfer.cancel() } + store?.clearSchedule(signedOut: payload[Key.signedOut] as? Bool ?? false) + return true + } + + /// 判断手机主动推送的轻量上下文是否代表一份尚未完整安装的新课表。 + @MainActor + private func applicationContextRequiresFullSync( + _ context: [String: Any] + ) -> Bool { + guard let version = context[Key.scheduleVersion] as? String, + !version.isEmpty + else { + return false + } + return version != store?.installedScheduleVersion + } + + /// 从任意 WatchConnectivity 载荷中安装手机指定语言。 + @MainActor + private func consumePreferredLanguage(from payload: [String: Any]) { + guard let language = payload[Key.preferredLanguage] as? String else { + return + } + _ = store?.setPreferredLanguage(language) + } + + /// 标记当前启动请求已收到手机的新回复,并撤下离线提示。 + /// + /// 消息请求回复必须匹配本轮关联的刷新 ID;Application Context 的代理 + /// 回调本身代表当轮新到达的状态,因此可以不传 ID。磁盘中的 Context 由主动 + /// `consumeLatestApplicationContext` 读取,不会经过这里,也不会误判。 + @MainActor + private func receiveCurrentPhoneReply(refreshID: UUID? = nil) { + guard launchAttemptID != nil else { return } + if let refreshID, + refreshID != launchAttemptRefreshID + { + return + } + launchAttemptID = nil + launchAttemptRefreshID = nil + launchReplyTimeoutTask?.cancel() + launchReplyTimeoutTask = nil + store?.receiveLaunchSyncReply() + } + + /// 新刷新和明确清空都会废弃旧请求,旧闭包不能影响后续阶段。 + @MainActor + private func resetIncomingTransfer() { + activeIncomingScheduleVersion = nil + activeIncomingAccountGeneration = nil + pendingRequest = nil + semesterBufferWasDiscarded = false + } + + @MainActor + private func cancelRefreshTimeout() { + refreshTimeoutTask?.cancel() + refreshTimeoutTask = nil + } + + /// 即时回调使用发送闭包捕获的 ID,队列回调使用手机回传的 ID。 + /// scope 缺失时可回退到这里保留的请求范围,不能从回复自身推断预期范围。 + @MainActor + private func isPendingRequest(_ requestID: String, refreshID: UUID) -> Bool { + isActiveRefresh(refreshID) && pendingRequest?.id == requestID + } + + /// 判断异步回调是否仍属于最新一轮刷新。 + private func isActiveRefresh(_ expectedRefreshID: UUID) -> Bool { + expectedRefreshID == refreshID + } +} diff --git a/watchOS/Localizable.xcstrings b/watchOS/Localizable.xcstrings new file mode 100644 index 00000000..e7df279e --- /dev/null +++ b/watchOS/Localizable.xcstrings @@ -0,0 +1,3753 @@ +{ + "sourceLanguage" : "zh-Hans", + "strings" : { + "" : { + "shouldTranslate" : false + }, + "–" : { + "shouldTranslate" : false + }, + "%@ – %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ – %2$@" + } + } + }, + "shouldTranslate" : false + }, + "%@ %d 项" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ · Events: %2$d" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ %d 项" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$d 項" + } + } + } + }, + "%@ %d 项 · 还剩 %d 项" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ · Events: %2$d · Left: %3$d" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ %d 项 · 还剩 %d 项" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$d 項 · 還剩 %3$d 項" + } + } + } + }, + "%@ 全部结束" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done at %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 全部结束" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 全部結束" + } + } + } + }, + "%1$@,第%2$lld到第%3$lld节" : { + "comment" : "VoiceOver 对课程名称和节次范围的描述", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, periods %2$lld–%3$lld" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@,第%2$lld到第%3$lld節" + } + } + } + }, + "%lld" : { + "shouldTranslate" : false + }, + "App 操作教程" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "App tutorial" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "App 操作教程" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "App 操作教學" + } + } + } + }, + "Apple Watch 课表" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Watch Schedule" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple Watch 課表" + } + } + } + }, + "上一项" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Previous item" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "上一項" + } + } + } + }, + "上课" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Starts" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上课" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "上課" + } + } + } + }, + "下一场考试" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Next exam" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下一场考试" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "下一場考試" + } + } + } + }, + "下一次 %@" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Next: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下一次 %@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "下一次 %@" + } + } + } + }, + "下一步添加示意" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Next setup step" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下一步添加示意" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "下一步加入示意" + } + } + } + }, + "下一节" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Up next" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下一节" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "下一節" + } + } + } + }, + "下一项" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Next item" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "下一項" + } + } + } + }, + "下一项实验" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Next lab" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下一项实验" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "下一項實驗" + } + } + } + }, + "下课" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ends" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下课" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "下課" + } + } + } + }, + "今日" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Today" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日" + } + } + } + }, + "今日安排已完成" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done for today" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日安排已完成" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日安排已完成" + } + } + } + }, + "今日已下课" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done for today" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日已下课" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日已下課" + } + } + } + }, + "今日无课" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No classes today" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日无课" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日無課" + } + } + } + }, + "今日概览待同步" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Today’s summary needs a sync" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日概览待同步" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日概覽待同步" + } + } + } + }, + "今日没有安排" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nothing scheduled today" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日没有安排" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日沒有安排" + } + } + } + }, + "今日还剩 %d 项" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Events left today: %d" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日还剩 %d 项" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日還剩 %d 項" + } + } + } + }, + "今日还剩 %lld 项" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Today: %lld left" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日还剩 %lld 项" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日還剩 %lld 項" + } + } + } + }, + "从手机刷新" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Refresh from iPhone" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "從手機重新整理" + } + } + } + }, + "使用指南" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Guides" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用指南" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用指南" + } + } + } + }, + "全学期课表无法合并" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to merge the semester schedule" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "全學期課表無法合併" + } + } + } + }, + "关闭新手引导提示" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dismiss onboarding tip" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "關閉新手引導提示" + } + } + } + }, + "关闭缓存提示" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dismiss cached schedule notice" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "關閉快取課表提示" + } + } + } + }, + "关闭详情" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close Details" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "關閉詳情" + } + } + } + }, + "关闭课程详情" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close class details" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "關閉課程詳情" + } + } + } + }, + "关闭课表过期提示" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dismiss outdated schedule notice" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关闭课表过期提示" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "關閉課表過期提示" + } + } + } + }, + "切换当前与下一节课" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Switch current and next class" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "切換當前與下一節課" + } + } + } + }, + "切换视图" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Switch View" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "切換檢視" + } + } + } + }, + "切换课表视图" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Switch schedule view" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "切換課表檢視" + } + } + } + }, + "刷新课表" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Refresh Schedule" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "重新整理課表" + } + } + } + }, + "即将上课" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Starting soon" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "即将上课" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "即將上課" + } + } + } + }, + "同步失败:%@" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sync failed: %@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "同步失敗:%@" + } + } + } + }, + "同步完成" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sync Complete" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "同步完成" + } + } + } + }, + "后天" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "In 2 days" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "后天" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "後天" + } + } + } + }, + "后续课表待同步" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sync upcoming schedule" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "后续课表待同步" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "後續課表待同步" + } + } + } + }, + "周视图" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Week View" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "週檢視" + } + } + } + }, + "圆形" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Circle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "圆形" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "圓形" + } + } + } + }, + "圆形:快速查看时间与地点。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Circle: see the time and room quickly." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "圆形:快速查看时间与地点。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "圓形:快速查看時間與地點。" + } + } + } + }, + "地点待定" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Location TBD" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "地点待定" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "地點待定" + } + } + } + }, + "复杂功能" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Complications" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "复杂功能" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "複雜功能" + } + } + } + }, + "好" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "好" + } + } + } + }, + "学期尚未开始" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Term not started" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "学期尚未开始" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "學期尚未開始" + } + } + } + }, + "学期结束啦" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Term finished" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "学期结束啦" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "學期結束啦" + } + } + } + }, + "完成" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "完成" + } + } + } + }, + "实验" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Experiment" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "實驗" + } + } + } + }, + "小组件使用指南" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Widget guide" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "小组件使用指南" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "小工具使用指南" + } + } + } + }, + "已加载缓存课表" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cached schedule loaded" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已載入快取課表" + } + } + } + }, + "已完成 %d 项" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d completed" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已完成 %d 项" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已完成 %d 項" + } + } + } + }, + "底层数据已准备完成" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schedule data is ready" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "底層資料已準備完成" + } + } + } + }, + "座位 %@" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seat %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "座位 %@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "座位 %@" + } + } + } + }, + "当前课程 ID" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Current Class ID" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "當前課程 ID" + } + } + } + }, + "当天没有课程" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Classes Today" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "當天沒有課程" + } + } + } + }, + "待做实验 %d 项" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upcoming labs: %d" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "待做实验 %d 项" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "待做實驗 %d 項" + } + } + } + }, + "待考 %d 场" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upcoming exams: %d" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "待考 %d 场" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "待考 %d 場" + } + } + } + }, + "悬浮按钮" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Controls" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "懸浮按鈕" + } + } + } + }, + "手机端暂无课表,请先刷新手机课表" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No schedule is available on iPhone. Refresh it first." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "手機端暫無課表,請先重新整理手機課表" + } + } + } + }, + "手机端暂无课表,请先在 iPhone 打开并刷新课表" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No schedule is available. Open the app on iPhone and refresh it first." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "手機端暫無課表,請先在 iPhone 開啟並重新整理課表" + } + } + } + }, + "手机端没有可用的学期课表" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No semester schedule is available on iPhone" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "手機端沒有可用的學期課表" + } + } + } + }, + "打开手机更新课表" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open iPhone to update" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开手机更新课表" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "打開手機更新課表" + } + } + } + }, + "打开日期选择器" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Date Picker" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "開啟日期選擇器" + } + } + } + }, + "把课表放到表盘上" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add to watch face" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "把课表放到表盘上" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "把課表放到錶面上" + } + } + } + }, + "抬腕,就知道下一节" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your next class" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "抬腕,就知道下一节" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "抬腕,就知道下一節" + } + } + } + }, + "按住右下角切换按钮三秒,感受逐渐增强的震动。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hold the bottom-right button for 3 seconds as taps strengthen." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "按住右下角切换按钮三秒,感受逐渐增强的震动。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "按住右下角切換按鈕三秒,感受逐漸增強的震動。" + } + } + } + }, + "操作正确" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Correct action" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "操作正確" + } + } + } + }, + "操作错误" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Incorrect action" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "操作錯誤" + } + } + } + }, + "旋转数码表冠,以浏览当前课程和下一节课程。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Turn the Digital Crown to browse the current and next class." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "旋轉數碼錶冠,以瀏覽目前課程和下一節課程。" + } + } + } + }, + "旋转数码表冠,以浏览当前页面中的课程。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Turn the Digital Crown to browse the courses on this page." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "旋轉數碼錶冠,以瀏覽目前頁面中的課程。" + } + } + } + }, + "旋转数码表冠,以连续切换前后周。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Turn the Digital Crown to move continuously between weeks." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "旋轉數碼錶冠,以連續切換前後週。" + } + } + } + }, + "旋转数码表冠,以连续切换前后月份。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Turn the Digital Crown to move continuously between months." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "旋轉數碼錶冠,以連續切換前後月份。" + } + } + } + }, + "无法连接 iPhone:%@" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to connect to iPhone: %@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法連線 iPhone:%@" + } + } + } + }, + "日期选择器" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Date Picker" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "日期選擇器" + } + } + } + }, + "日视图" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Day View" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "日檢視" + } + } + } + }, + "日程概览" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schedule overview" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "日程概览" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "日程概覽" + } + } + } + }, + "日程概览待同步" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sync schedule overview" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "日程概览待同步" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "日程概覽待同步" + } + } + } + }, + "时间地点" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Time & Location" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "時間地點" + } + } + } + }, + "明天" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tomorrow" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "明天" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "明天" + } + } + } + }, + "明天%@" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tomorrow %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "明天%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "明天%@" + } + } + } + }, + "明日" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tomorrow" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "明日" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "明日" + } + } + } + }, + "显示上课、下课时间与地点,与课程名称组件保持一致。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Class start and end times and location, following the same class as Course Name." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示上课、下课时间与地点,与课程名称组件保持一致。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "顯示上課、下課時間與地點,與課程名稱元件保持一致。" + } + } + } + }, + "显示今日剩余安排、结束时间和本周总数,轻点打开概览。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shows today’s remaining events, finish time, and weekly total. Tap to open Overview." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示今日剩余安排、结束时间和本周总数,轻点打开概览。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "顯示今日剩餘安排、結束時間和本週總數,輕點打開概覽。" + } + } + } + }, + "显示当前或下一节课,上课期间显示课程进度。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shows the current or next class, with progress during class." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示当前或下一节课,上课期间显示课程进度。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "顯示目前或下一節課,上課期間顯示課程進度。" + } + } + } + }, + "显示课程名称和状态,搭配时间地点组件使用。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Class name and status. Pair with Time & Location." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示课程名称和状态,搭配时间地点组件使用。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "顯示課程名稱和狀態,搭配時間地點元件使用。" + } + } + } + }, + "暂无课程" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Classes" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "暫無課程" + } + } + } + }, + "暂无课表" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Schedule" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "暫無課表" + } + } + } + }, + "暂时无法连接 iPhone" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to connect to iPhone right now" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "暫時無法連線 iPhone" + } + } + } + }, + "更新请打开手机 XDYou 以同步" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open XDYou on iPhone to update" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "如需更新,請在手機開啟 XDYou 同步" + } + } + } + }, + "月视图" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Month View" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "月檢視" + } + } + } + }, + "本周" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This week" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "本周" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "本週" + } + } + } + }, + "本周日程待同步" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sync this week’s schedule" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "本周日程待同步" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "本週日程待同步" + } + } + } + }, + "本周暂无待考" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No upcoming exams this week" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "本周暂无待考" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "本週暫無待考" + } + } + } + }, + "本周没有后续安排" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nothing else scheduled this week" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "本周没有后续安排" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "本週沒有後續安排" + } + } + } + }, + "本周还有 %d 天安排" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scheduled days left this week: %d" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "本周还有 %d 天安排" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "本週還有 %d 天安排" + } + } + } + }, + "本学期后续无课" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No more classes this term" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "本学期后续无课" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "本學期後續無課" + } + } + } + }, + "本学期结束,开心玩耍吧!" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Term finished. Enjoy your break!" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "本学期结束,开心玩耍吧!" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "本學期結束,開心玩耍吧!" + } + } + } + }, + "查看课程" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "View Course" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "查看課程" + } + } + } + }, + "概览" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overview" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "概覽" + } + } + } + }, + "欢迎使用 XDYou" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Welcome to XDYou" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "歡迎使用 XDYou" + } + } + } + }, + "正在上课" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "In class" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在上课" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在上課" + } + } + } + }, + "正在从手机同步课表" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Syncing schedule from iPhone" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在從手機同步課表" + } + } + } + }, + "正在刷新" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Refreshing" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在重新整理" + } + } + } + }, + "正在加载新手引导" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preparing the guide…" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在載入新手引導…" + } + } + } + }, + "正在实验" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lab in progress" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在实验" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在實驗" + } + } + } + }, + "正在考试" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exam in progress" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在考试" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在考試" + } + } + } + }, + "正在获取今天" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fetching Today" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在取得今天" + } + } + } + }, + "正在获取整个学期" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fetching Semester" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在取得整個學期" + } + } + } + }, + "正在获取近 14 天" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fetching 14 Days" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在取得近 14 天" + } + } + } + }, + "此设备不支持与 iPhone 同步" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This device does not support iPhone sync" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此裝置不支援與 iPhone 同步" + } + } + } + }, + "浏览课程" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Browse Courses" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "瀏覽課程" + } + } + } + }, + "添加后自动更新,轻点组件即可进入概览。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Updates automatically. Tap to open Overview." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加后自动更新,轻点组件即可进入概览。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "加入後自動更新,輕點小工具即可進入概覽。" + } + } + } + }, + "长按表盘空白处\n进入表盘编辑模式" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hold the face\nto start editing." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "长按表盘空白处\n进入表盘编辑模式" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "長按錶盤空白處\n進入錶盤編輯模式" + } + } + } + }, + "物理实验" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Physics Experiment" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "物理實驗" + } + } + } + }, + "用手指上下滑动屏幕,以浏览当前课程和下一节课程。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Swipe up or down to browse the current and next class." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "用手指上下滑動螢幕,以瀏覽目前課程和下一節課程。" + } + } + } + }, + "用手指上下滑动屏幕,以浏览当前页面中的课程。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Swipe up or down to browse the courses on this page." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "用手指上下滑動螢幕,以瀏覽目前頁面中的課程。" + } + } + } + }, + "用手指再次轻点页面空白处,以显示右侧操作按钮。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap an empty area again to show the controls on the right." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "用手指再次輕點頁面空白處,以顯示右側操作按鈕。" + } + } + } + }, + "用手指左右滑动屏幕,以切换前后周。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Swipe left or right to move between weeks." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "用手指左右滑動螢幕,以切換前後週。" + } + } + } + }, + "用手指左右滑动屏幕,以切换前后日期。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Swipe left or right to move between dates." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "用手指左右滑動螢幕,以切換前後日期。" + } + } + } + }, + "用手指左右滑动屏幕,以切换前后月份。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Swipe left or right to move between months." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "用手指左右滑動螢幕,以切換前後月份。" + } + } + } + }, + "用手指轻点一个日期,以切换到该日期的日视图。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap a date to open its day view." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "用手指輕點一個日期,以切換到該日期的日視圖。" + } + } + } + }, + "用手指轻点一个日期,以打开该日期的日视图。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap a date to open its day view." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "用手指輕點一個日期,以開啟該日期的日視圖。" + } + } + } + }, + "用手指轻点关闭按钮,以返回周视图。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap Close to return to Week View." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "用手指輕點關閉按鈕,以返回週視圖。" + } + } + } + }, + "用手指轻点刷新按钮,以从 iPhone 更新课表。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap Refresh to update the schedule from your iPhone." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "用手指輕點重新整理按鈕,以從 iPhone 更新課表。" + } + } + } + }, + "用手指轻点右下角切换按钮,以打开视图目录。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap the bottom-right view button to open the view menu." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "用手指輕點右下角切換按鈕,以開啟檢視目錄。" + } + } + } + }, + "用手指轻点右侧箭头,以切换到下一页。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap the right arrow to go to the next page." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "用手指輕點右側箭頭,以切換到下一頁。" + } + } + } + }, + "用手指轻点左侧箭头,以切换到上一页。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap the left arrow to go to the previous page." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "用手指輕點左側箭頭,以切換到上一頁。" + } + } + } + }, + "用手指轻点页面空白处,以隐藏右侧操作按钮。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap an empty area to hide the controls on the right." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "用手指輕點頁面空白處,以隱藏右側操作按鈕。" + } + } + } + }, + "用手指轻点顶部日期标题,以打开日期选择器。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap the date title at the top to open the date picker." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "用手指輕點頂部日期標題,以開啟日期選擇器。" + } + } + } + }, + "用手指轻点顶部月份标题,以退出月视图。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap the month title at the top to exit Month View." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "用手指輕點頂部月份標題,以退出月視圖。" + } + } + } + }, + "用手指轻点高亮的课程色块,以打开课程详情。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap the highlighted course block to open course details." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "用手指輕點高亮的課程色塊,以開啟課程詳情。" + } + } + } + }, + "第 %lld 页,共 %lld 页" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Page %1$lld of %2$lld" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "第 %lld 页,共 %lld 页" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "第 %1$lld 頁,共 %2$lld 頁" + } + } + } + }, + "第%lld周" : { + "comment" : "学期周次标题", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Week %lld" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "第%lld週" + } + } + } + }, + "第一次载入课表,正在渲染底层数据" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preparing your schedule for the first time…" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "第一次載入課表,正在準備底層資料" + } + } + } + }, + "综合课表" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Smart schedule" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "综合课表" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "綜合課表" + } + } + } + }, + "编辑" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "編輯" + } + } + } + }, + "编辑 → 复杂功能" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit → Complications" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑 → 复杂功能" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "編輯 → 複雜功能" + } + } + } + }, + "翻页" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Page" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "翻頁" + } + } + } + }, + "考试" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exam" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "考試" + } + } + } + }, + "表角" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Corner" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "表角" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "錶角" + } + } + } + }, + "请先同步课表" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sync your schedule" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请先同步课表" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "請先同步課表" + } + } + } + }, + "请在手机登录" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign in on iPhone" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请在手机登录" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "請在手機登入" + } + } + } + }, + "请在配对的 iPhone 上打开应用并刷新课表" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open the app on the paired iPhone and refresh the schedule" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "請在配對的 iPhone 上開啟 App 並重新整理課表" + } + } + } + }, + "请打开手机 XDYou 并单击刷新按钮" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open XDYou on iPhone and tap Refresh" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "請在手機開啟 XDYou 並點一下重新整理按鈕" + } + } + } + }, + "课程 %d · 考试 %d · 实验 %d" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Classes %1$d · Exams %2$d · Labs %3$d" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "课程 %d · 考试 %d · 实验 %d" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "課程 %1$d · 考試 %2$d · 實驗 %3$d" + } + } + } + }, + "课程列表" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schedule" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "課程列表" + } + } + } + }, + "课程名称" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Class Name" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "課程名稱" + } + } + } + }, + "课程详情" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Course Details" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "課程詳情" + } + } + } + }, + "课程进度" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Class Progress" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "課程進度" + } + } + } + }, + "课表分页数据无效,请重新刷新" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invalid schedule page. Refresh again." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "课表分页数据无效,请重新刷新" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "課表分頁資料無效,請重新整理" + } + } + } + }, + "课表可能已过期" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schedule May Be Outdated" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "課表可能已過期" + } + } + } + }, + "课表待更新" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schedule outdated" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "课表待更新" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "課表待更新" + } + } + } + }, + "课表数据无法读取" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to read schedule data" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "課表資料無法讀取" + } + } + } + }, + "轻点以继续" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap to continue" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "輕點以繼續" + } + } + } + }, + "轻点屏幕以开始" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap the screen to begin" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "輕點螢幕以開始" + } + } + } + }, + "还剩" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Left" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "还剩" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "還剩" + } + } + } + }, + "还剩 %d 项" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d left" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "还剩 %d 项" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "還剩 %d 項" + } + } + } + }, + "还有一个更快的方法" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "There’s a quicker way" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "还有一个更快的方法" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "還有一個更快的方法" + } + } + } + }, + "连续旋转数码表冠并越过课程边界,以连续切换日期。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keep turning the Digital Crown past the course boundary to move between dates." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "持續旋轉數碼錶冠並越過課程邊界,以連續切換日期。" + } + } + } + }, + "退出" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exit" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "退出" + } + } + } + }, + "选择 XDYou" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose XDYou" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择 XDYou" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "選擇 XDYou" + } + } + } + }, + "选择日期" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose a Date" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "選擇日期" + } + } + } + }, + "选择适合你的表盘" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Find your fit" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择适合你的表盘" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "選擇適合你的錶面" + } + } + } + }, + "重新打开引导" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Replay Guide" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "重新開啟引導" + } + } + } + }, + "长按右下角切换按钮重新进入新手引导" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Long-press the bottom-right view button to replay the guide" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "長按右下角切換按鈕重新進入新手引導" + } + } + } + }, + "长按表盘" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Touch and hold the watch face" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "长按表盘" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "長按錶面" + } + } + } + }, + "长按重新进入新手引导" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Long-press to replay the guide" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "長按重新進入新手引導" + } + } + } + }, + "长方形" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rectangle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "长方形" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "長方形" + } + } + } + }, + "长方形:课程、时间、地点与教师更完整。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rectangle: course, times, room and teacher together." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "长方形:课程、时间、地点与教师更完整。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "長方形:課程、時間、地點與教師更完整。" + } + } + } + }, + "表角:沿表盘边缘查看课程与进度。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Corner: see the course and progress along the edge." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "表角:沿表盘边缘查看课程与进度。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "錶角:沿錶盤邊緣查看課程與進度。" + } + } + } + }, + "工程概论 (IV)" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Introduction to Engineering (IV)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "工程概论 (IV)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "工程概論 (IV)" + } + } + } + }, + "切换组件示例" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Change widget example" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "切换组件示例" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "切換組件範例" + } + } + } + }, + "轻点图片查看其他组件和状态。" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap the image to see more widgets and states." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "轻点图片查看其他组件和状态。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "點一下圖片查看其他組件與狀態。" + } + } + } + }, + "教程结束" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tutorial complete" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "教程结束" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "教學結束" + } + } + } + }, + "开始愉快的使用吧" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enjoy using XDYou!" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开始愉快的使用吧" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "開始愉快地使用吧" + } + } + } + }, + "课中看时间和进度\n课后显示下一节课" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Track progress\nSee the next class" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "课中看时间和进度\n课后显示下一节课" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "課中看時間與進度\n課後顯示下一節課" + } + } + } + }, + "轻点“编辑”\n滑到“复杂功能”" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap Edit, swipe to\nComplications." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "轻点“编辑”\n滑到“复杂功能”" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "點一下「編輯」\n滑到「複雜功能」" + } + } + } + }, + "轻点一个组件位置\n选择 XDYou 小组件" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap a widget slot\nChoose XDYou" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "轻点一个组件位置\n选择 XDYou 小组件" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "點選小工具位置\n選擇 XDYou 小工具" + } + } + } + }, + "沿表盘边缘看课程与进度" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Class and progress at the edge" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "沿表盘边缘看课程与进度" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "沿錶盤邊緣看課程與進度" + } + } + } + }, + "课程、时间、地点与教师" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Class, time, room and teacher" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "课程、时间、地点与教师" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "課程、時間、地點與教師" + } + } + } + }, + "当前或下一节课的名称" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Current or next class name" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前或下一节课的名称" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "目前或下一堂課的名稱" + } + } + } + }, + "今日剩余安排与结束状态" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Events left and when you're done" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日剩余安排与结束状态" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日剩餘安排與結束狀態" + } + } + } + }, + "下课时间、地点与进度" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "End time, room and progress" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下课时间、地点与进度" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "下課時間、地點與進度" + } + } + } + }, + "下次上课的时间与地点" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Time and room for the next class" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下次上课的时间与地点" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "下次上課的時間與地點" + } + } + } + }, + "高等数学" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calculus" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "高等数学" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "高等數學" + } + } + } + } + }, + "version" : "1.0" +} diff --git a/watchOS/Models/WatchScheduleSnapshot.swift b/watchOS/Models/WatchScheduleSnapshot.swift new file mode 100644 index 00000000..971480f7 --- /dev/null +++ b/watchOS/Models/WatchScheduleSnapshot.swift @@ -0,0 +1,310 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import Foundation +import SwiftUI + +/// 手机向手表分阶段发送课表时使用的范围。 +/// +/// 分阶段的目的,是让当天内容最快出现;随后再逐步扩大到 14 天和整个学期。 +extension WatchScheduleScope { + /// 刷新动画旁显示的当前阶段本地化文字。 + var progressTitle: String { + switch self { + case .today: + watchLocalizedString("正在获取今天") + case .fourteenDays: + watchLocalizedString("正在获取近 14 天") + case .semester: + watchLocalizedString("正在获取整个学期") + } + } +} + +/// 节次推断使用的标准时间边界,单位为当天零点后的分钟数。 +/// +/// 只有旧缓存或特殊日程缺少明确节次时才使用这些边界。正常课程会直接采用 +/// 手机端传来的 `startSection` 和 `endSection`,因此不会受推断误差影响。 +private enum CoursePeriodReference { + static let validRange = 1...11 + static let startMinutes = [ + 510, 560, 625, 675, 840, 890, 955, 1005, 1140, 1195, 1240, + ] + static let endMinutes = [ + 555, 605, 670, 720, 885, 935, 1000, 1050, 1185, 1235, 1285, + ] +} + +/// 手表端统一使用的日程模型。 +/// +/// 名称沿用 `WatchCourse` 以保持缓存兼容,但 `kind` 也可以表示考试和实验。 +struct WatchCourse: Codable, Hashable, Identifiable { + let id: String + let name: String + let teacher: String? + let classroom: String? + let startAtEpochMs: Int64 + let endAtEpochMs: Int64 + let startSection: Int? + let endSection: Int? + let colorARGB: Int64? + let kind: String? + let note: String? + + /// 把手机传来的毫秒时间戳转换为 Foundation 日期。 + var startAt: Date { + WatchScheduleDate.date(fromEpochMilliseconds: startAtEpochMs) + } + + /// 日程结束时间。 + var endAt: Date { + WatchScheduleDate.date(fromEpochMilliseconds: endAtEpochMs) + } + + /// 开始节次。缺少明确节次时,按开始时间寻找最近的标准节次。 + var startPeriod: Int { + normalizedPeriod(startSection) ?? inferredPeriod( + from: startAt, + isStart: true + ) + } + + /// 结束节次。缺少明确节次时,按结束时间寻找最近的标准节次。 + var endPeriod: Int { + normalizedPeriod(endSection) ?? inferredPeriod( + from: endAt, + isStart: false + ) + } + + /// 将手机端 ARGB 颜色转换为 SwiftUI 颜色。 + var color: Color { + let value = unsignedARGBValue() + return Color( + red: colorComponent(value, shift: 16), + green: colorComponent(value, shift: 8), + blue: colorComponent(value, shift: 0) + ) + } + + /// 在详情页中显示的日程类型名称;普通课程无需额外标签。 + var kindTitle: String? { + switch kind { + case "exam": + watchLocalizedString("考试") + case "physicsExperiment": + watchLocalizedString("物理实验") + case "otherExperiment": + watchLocalizedString("实验") + default: + nil + } + } + + /// 与日程类型匹配的 SF Symbol。 + var kindSystemImage: String { + switch kind { + case "exam": + "pencil.and.list.clipboard" + case "physicsExperiment", "otherExperiment": + "flask" + default: + "book.closed" + } + } + + var isExam: Bool { kind == "exam" } + var isExperiment: Bool { kind == "physicsExperiment" || kind == "otherExperiment" } + + var classroomText: String? { WatchScheduleText.nonempty(classroom) } + + /// 只翻译快照生成器添加的考试座位前缀,缓存和学校原始备注保持原文。 + var localizedNote: String? { + guard let note = WatchScheduleText.nonempty(note) else { return nil } + guard isExam, note.hasPrefix("座位 "), + let seat = WatchScheduleText.nonempty(String(note.dropFirst(3))) + else { return note } + return watchLocalizedFormat("座位 %@", seat) + } + + /// App 卡片和 Widget 共用位置/教师/考试备注的选择及空值处理。 + func locationSummary( + includingDetails: Bool, + fallbackLocation: String? = nil + ) -> (text: String, systemImage: String)? { + let location = classroomText ?? fallbackLocation + var values = [String]() + if let location { values.append(location) } + if includingDetails, + let details = WatchScheduleText.singleLine(isExam ? localizedNote : teacher) { + values.append(details) + } + guard !values.isEmpty else { return nil } + let symbol = location != nil ? "mappin.and.ellipse" : (isExam ? "number.square" : "person") + return (values.joined(separator: " · "), symbol) + } + + /// 只接受课表支持范围内的明确节次。 + private func normalizedPeriod(_ value: Int?) -> Int? { + guard let value, + CoursePeriodReference.validRange.contains(value) + else { + return nil + } + return value + } + + /// 根据时间与标准节次边界的距离推断最接近的节次。 + private func inferredPeriod(from date: Date, isStart: Bool) -> Int { + let minutes = minutesSinceStartOfDay(for: date) + let boundaries = isStart + ? CoursePeriodReference.startMinutes + : CoursePeriodReference.endMinutes + return nearestPeriod(to: minutes, boundaries: boundaries) + } + + /// 计算某个时间是当天第几分钟。 + private func minutesSinceStartOfDay(for date: Date) -> Int { + let components = Calendar.current.dateComponents( + [.hour, .minute], + from: date + ) + return (components.hour ?? 0) * 60 + (components.minute ?? 0) + } + + /// 从标准边界中选择距离目标分钟数最近的一项。 + private func nearestPeriod( + to minutes: Int, + boundaries: [Int] + ) -> Int { + let nearest = boundaries.enumerated().min { + abs($0.element - minutes) < abs($1.element - minutes) + } + return (nearest?.offset ?? 0) + 1 + } + + /// 将可能缺失的 ARGB 值转为无符号位图,默认使用 Material 蓝色。 + private func unsignedARGBValue() -> UInt64 { + UInt64(bitPattern: colorARGB ?? Int64(0xFF2196F3)) + } + + /// 从 ARGB 位图中提取并归一化单个 RGB 通道。 + private func colorComponent( + _ value: UInt64, + shift: UInt64 + ) -> Double { + Double((value >> shift) & 0xFF) / 255 + } +} + +/// 一次完整同步阶段的课表快照。 +/// +/// 快照包含有效期和范围边界,因此 App 与 Widget 可以判断何时使用缓存、 +/// 何时回退到其他阶段的数据。 +struct WatchScheduleSnapshot: Codable, Equatable { + let schemaVersion: Int + let generatedAtEpochMs: Int64 + let semesterStartEpochMs: Int64? + let currentWeekIndex: Int? + let validThroughEpochMs: Int64 + let rangeStartEpochMs: Int64? + let rangeEndEpochMs: Int64? + let timeZoneOffsetMinutes: Int + let reminderMinutes: Int + let courses: [WatchCourse] + /// 完整学期边界,在当天/14 天切片中也保留。旧协议缺少时由学期缓存补齐。 + var semesterEndEpochMs: Int64? = nil + /// 手机单调递增的状态修订号,用于时钟调整后的缓存排序。 + var sourceRevision: Int64? = nil + var freshnessStamp: Int64 { sourceRevision ?? generatedAtEpochMs } + + /// 快照在手机端生成的时间。 + var generatedAt: Date { + WatchScheduleDate.date(fromEpochMilliseconds: generatedAtEpochMs) + } + + /// 学期第一周参考日期;旧数据可能没有该字段。 + var semesterStart: Date? { + guard let semesterStartEpochMs else { return nil } + return WatchScheduleDate.date(fromEpochMilliseconds: semesterStartEpochMs) + } + + /// 当前缓存建议被视为有效的截止时间。 + var validThrough: Date { + WatchScheduleDate.date(fromEpochMilliseconds: validThroughEpochMs) + } + + /// 快照覆盖范围的开始;旧缓存缺少范围时使用最早日程,不依赖输入排序。 + var rangeStart: Date { + guard let rangeStartEpochMs else { + return courses.min(by: { $0.startAtEpochMs < $1.startAtEpochMs })?.startAt ?? generatedAt + } + return WatchScheduleDate.date(fromEpochMilliseconds: rangeStartEpochMs) + } + + /// 快照覆盖范围的结束;旧缓存缺少范围时使用最晚结束时间。 + var rangeEnd: Date { + guard let rangeEndEpochMs else { + return courses.max(by: { $0.endAtEpochMs < $1.endAtEpochMs })?.endAt ?? validThrough + } + return WatchScheduleDate.date(fromEpochMilliseconds: rangeEndEpochMs) + } + + /// 分页合并只替换课程数组,所有协议元数据集中在这里保留。 + func replacingCourses(_ courses: [WatchCourse]) -> Self { + Self( + schemaVersion: schemaVersion, + generatedAtEpochMs: generatedAtEpochMs, + semesterStartEpochMs: semesterStartEpochMs, + currentWeekIndex: currentWeekIndex, + validThroughEpochMs: validThroughEpochMs, + rangeStartEpochMs: rangeStartEpochMs, + rangeEndEpochMs: rangeEndEpochMs, + timeZoneOffsetMinutes: timeZoneOffsetMinutes, + reminderMinutes: reminderMinutes, + courses: courses, + semesterEndEpochMs: semesterEndEpochMs, + sourceRevision: sourceRevision + ) + } +} + +/// 分页事务只在内存累积;元数据一致且最后一页完成后才能生成可安装快照。 +struct WatchSemesterTransfer { + private var metadata: WatchScheduleSnapshot? + private var coursesByID: [String: WatchCourse] = [:] + + mutating func append(_ chunk: WatchScheduleSnapshot) throws { + let incomingMetadata = chunk.replacingCourses([]) + if let metadata, metadata != incomingMetadata { + throw WatchScheduleDataError.inconsistentSemester + } + metadata = incomingMetadata + for course in chunk.courses { coursesByID[course.id] = course } + } + + func completedSnapshot() throws -> WatchScheduleSnapshot { + guard let metadata else { throw WatchScheduleDataError.inconsistentSemester } + return metadata.replacingCourses(WatchScheduleResolver.sorted(Array(coursesByID.values))) + } + + mutating func reset(keepingCapacity: Bool) { + metadata = nil + coursesByID.removeAll(keepingCapacity: keepingCapacity) + } +} + +/// 数据层错误只进入现有诊断日志;用户提示由调用方使用本地化资源提供。 +enum WatchScheduleDataError: LocalizedError { + case unsupportedSchema(Int) + case inconsistentSemester + case outdatedSnapshot + + var errorDescription: String? { + switch self { + case .unsupportedSchema(let version): "Unsupported schedule schema: \(version)" + case .inconsistentSemester: "Semester chunks have inconsistent metadata" + case .outdatedSnapshot: "Schedule snapshot is older than the installed cache" + } + } +} diff --git a/watchOS/README.md b/watchOS/README.md new file mode 100644 index 00000000..bd2a81a9 --- /dev/null +++ b/watchOS/README.md @@ -0,0 +1,127 @@ +# XDYou Apple Watch + +本目录包含原生 SwiftUI watchOS Companion App、表盘复杂功能和 WidgetKit +Smart Stack 小组件。此文档介绍功能和源码入口;协议、缓存、交互生命周期及 +验证方法见 [XDYou Apple Watch 技术说明](apple_watch_technical_overview.md)。 + +## 数据与同步 + +iPhone 是课表数据源。Flutter 将课程、自定义课程、考试和实验展开成完整学期 +快照,iOS 原生层持久化快照并处理 WatchConnectivity 请求。手表只接收已展开 +到具体日期的日程,不保存校园账号凭据,也不直接请求学校接口。 + +每次打开或回到前台,手表携带已完整安装的课表版本与账号代次请求同步。两者 +都匹配时,手机返回轻量确认;需要更新时依次获取当天、近 14 天和分块传输的 +完整学期。当天与 14 天数据只替换各自覆盖范围,完整学期在分页接收完毕后 +原子安装。手机离线、同步失败或缓存局部损坏时,仍保留可用数据。 + +Watch App 与 Widget 共用简体中文、繁体中文和英语资源,界面语言跟随手机 +XDYou 中实际生效的选择。课程、地点、教师和学校备注保留原文。课程提醒由 +iPhone 本地通知转发,手表不重复调度。 + +## 浏览课表 + +右下角三点按钮可选择概览、课程列表、日视图、月视图和周视图。 + +- 概览显示今日课程、考试、实验及完成情况,正文最多两张卡片:当前项和 + 下一项。底部按已同步的覆盖范围补充本周安排天数及待考、待实验数量。 +- 课程列表按自然日展示整学期日程,支持触摸和数码表冠滚动。 +- 日视图浏览单日卡片,通过箭头、滑动或表冠翻日,日期标题可打开月历。 +- 周视图展示七日课程色块,支持连续翻周和点击课程查看详情。 +- 月视图提供日期选择、相邻月份分页及每日五段课程标记。 + +滚动或转动表冠会隐藏悬浮按钮;轻点空白区域可显示或隐藏。同步等待期间 +按钮保持可见。离线缓存提示与教程完成提示可轻点关闭,也会在 15 秒后消失。 + +## 新手引导与小组件指南 + +首次使用且已有本学期日程时进入黑色欢迎页。欢迎页准备好教学数据和首屏 +材质后,轻点开始实际操作教学;没有日程时先提示用户在 iPhone 刷新课表。 +实操依次覆盖概览、课程列表、日、周、月视图,最后练习长按三点按钮重新打开 +引导。每步区分点击、滑动与表冠输入,错误操作会给出反馈并恢复当前教学状态。 + +以下入口共用三秒长按反馈:按住 0.3 秒后开始触觉脉冲,力度逐渐增强、间隔 +逐渐缩短,满三秒即触发。松手、移出有效范围或系统取消会停止反馈。 + +- 正常浏览时长按三点按钮:重新开始完整引导;短按打开视图列表。 +- 黑色欢迎页任意位置长按:直接进入小组件指南;开始反馈后提前松手会留在 + 欢迎页。此快捷入口不依赖教学预热完成。 +- 最后一步长按练习完成:衔接小组件指南。该练习中误点三点按钮只显示错误 + 反馈,不打开视图列表。 + +小组件指南也可从视图列表的“使用指南 → 小组件使用指南”单独打开,无需真实 +课表。共有五页: + +1. 黑色扫光过渡页“还有一个更快的方法”,轻点后向左滑入下一页; +2. 展示综合课表的圆形、表角、长方形及课中、课后状态; +3. 选择三种形态,查看组件名称、状态与用途说明; +4. 演示“长按表盘 → 编辑 → 复杂功能 → 选择 XDYou”; +5. 黑色结束页,停留两秒后自动返回概览。 + +第 2–4 页卡片或操作示意每三秒循环,轻点可手动切换;滑动、表冠和分页点均可 +翻页。离开当前页或进入后台时停止播放。开启减少动态效果或 VoiceOver 时保留 +手动操作,停止自动轮播。结束倒计时只在第五页被实际选中且 App 在前台时运行。 +完整引导只在结束时保存完成状态,单独阅读指南不会修改该状态,也不代表用户 +已在系统中添加小组件。 + +指南使用 `Assets.xcassets/WidgetGuide/` 内的原图裁切素材。示例课程是固定图片, +周围说明和辅助功能文本按语言切换,不读取实时课表,也不触发 Widget 刷新。 + +## 表盘与 Smart Stack 小组件 + +| 组件 | 展示内容 | +| --- | --- | +| 综合课表 | 依据尺寸显示课程、时刻、地点及课中进度,可预览下一节 | +| 课程名称 | 当前或下一项的名称与状态 | +| 时间地点 | 与名称组件引用同一日程,显示时间、地点及课中进度 | +| 日程概览 | 今日剩余安排、结束状态,长方形额外显示本周数量 | + +四种组件均支持单行、圆形和长方形,表角仅支持综合课表。全部小组件点击后 +打开概览并回到顶部;综合课表的切换箭头只切换组件内的当前与下一节预览。 + +- 圆形综合课表与时间地点:课中使用系统开口圆环,显示下课时间与地点; + 课前显示上课时间与地点。无圆环时主要文字共用样式,圆形各角色和空状态 + 统一交给表盘着色。 +- 表角综合课表:课中显示课程名与系统弧形进度;其余时段显示开课日期时间, + 弧形标签为“精简位置 · 课程名”,地点优先保留。 +- 长方形:显示起止时间,课中进度位于时间与地点之间,底部为“位置 · 教师” + 或“位置 · 考试备注”。时刻和地点信息使用相同字重与字号。 +- 单行:只显示一个实际时刻,课前为上课时间,课中为下课时间。 + +圆形、单行和表角位置会省略“信远”,保留罗马数字和教室号。长方形与 App +使用完整位置。进度只属于正在进行的课程,下一节预览不显示当前课程进度。 +预览绑定当前课程,最迟五分钟失效;到下课或下一项上课时也会失效。 + +Widget 从 App Group 读取已校验缓存,复用共享课程状态与范围判断。日程统计 +要求相应时间范围完整有效,未知覆盖不会显示为零。时间线覆盖课程、日期和 +有效期边界,课中每五分钟安排进度节点;实际刷新时机由 WidgetKit 调度。 + +## 源码入口与维护 + +| 目录或文件 | 职责 | +| --- | --- | +| `../lib/repository/watch/` | 手机端日程展开、数据监听和同步桥接 | +| `../ios/Runner/WatchConnectivityManager.swift` | iPhone 快照、版本、范围回复和 Watch Host API 实现 | +| `../ios/Runner/PhoneWatchQueuedScheduleTransport.swift` | iPhone 后台请求与回复关联 | +| `Models/WatchScheduleSnapshot.swift` | 统一模型、数据校验与日期/节次计算 | +| `Connectivity/WatchConnectivityManager.swift` | 三阶段同步、请求关联与超时 | +| `Storage/WatchScheduleStore.swift` | 快照安装、缓存恢复、派生索引和预热 | +| `Storage/DayCourseLayoutCache.swift` | 卡片高度、位移换算和延迟持久化 | +| `Shared/WatchSyncSupport.swift` | 跨 iPhone/Watch 的协议、语言与文本日期函数 | +| `Shared/WatchWidgetShared.swift` | App Group、缓存编码、语言与 Widget 交互状态 | +| `Shared/WatchSchedulePresentation.swift` | 覆盖合并、App 概览和 Widget 共用的课程状态及摘要 | +| `Views/RootScheduleView.swift` | 顶层路由、同步提示、悬浮控件和引导生命周期 | +| `Views/CalendarPagingSupport.swift` | 日/周/月共用分页与吸附计算 | +| `Views/WatchInteractionSupport.swift` | 取消任务、按压、触觉和表冠会话 | +| `Views/WatchOnboardingView.swift` | 实操步骤、输入判定与提示 | +| `Views/Onboarding/` | 五页组件指南、图片映射、循环播放与安装示意 | +| `Widget/` | 四种组件、App Intent 与时间线 | + +可重建索引与卡片高度在后台编码,页面交互读取内存状态。日期网格与课程标记 +分层缓存,系统日历或时区变化时重建自然日分组。修改协议、缓存或共享状态时, +需同时核对 iPhone、Watch App 和 Widget 的调用方。 + +共享 Swift 回归入口为 `tools/test_watch_regressions.sh`,语言审计入口为 +`tools/audit_watch_localizations.py`。设备触觉、界面布局及配对传输效果已通过 +实体机操作验证,由项目维护者确认。2026-09-08 的 Watch/iPhone 模拟器构建、 +Swift 与 Flutter 回归、本地化及签名脚本检查均已通过;详情与复跑命令见技术说明。 diff --git a/watchOS/Shared/WatchSchedulePresentation.swift b/watchOS/Shared/WatchSchedulePresentation.swift new file mode 100644 index 00000000..7014670f --- /dev/null +++ b/watchOS/Shared/WatchSchedulePresentation.swift @@ -0,0 +1,482 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import Foundation + +/// 每段数据保留自己的有效期;局部刷新不能把旧学期的其他日期续期。 +struct WatchScheduleCoverage { + let start: Date + let end: Date + let validThrough: Date +} + +struct WatchResolvedSchedule { + let snapshot: WatchScheduleSnapshot + let coverage: [WatchScheduleCoverage] + let semesterEnd: Date? + let scope: WatchScheduleScope + + func covers(_ start: Date, through end: Date, at now: Date) -> Bool { + var cursor = start + for range in coverage.sorted(by: { $0.start < $1.start }) + where range.validThrough >= now && range.end > cursor { + if range.start > cursor { return false } + cursor = max(cursor, range.end) + if cursor >= end { return true } + } + return false + } +} + +enum WatchScheduleResolver { + /// 同一学期按数据修订号覆盖明确范围;旧协议回退到生成时间。 + /// 同一版本的完整快照最后安装。 + /// 空数组也具有删除语义,不能因为非空旧缓存而忽略。 + static func resolve( + _ caches: [WatchScheduleScope: WatchScheduleSnapshot] + ) -> WatchResolvedSchedule? { + let ordered = caches.sorted { + if $0.value.freshnessStamp != $1.value.freshnessStamp { + return $0.value.freshnessStamp < $1.value.freshnessStamp + } + return rank($0.key) < rank($1.key) + } + guard let newest = ordered.last else { return nil } + let sameTerm = ordered.filter { + $0.value.semesterStartEpochMs == newest.value.semesterStartEpochMs + } + var courses: [String: WatchCourse] = [:] + var coverage: [WatchScheduleCoverage] = [] + var semesterEnd: Date? + var scope = newest.key + for (incomingScope, incoming) in sameTerm { + let start = incoming.rangeStart + let end = incoming.rangeEnd + if let endEpoch = incoming.semesterEndEpochMs { + semesterEnd = WatchScheduleDate.date(fromEpochMilliseconds: endEpoch) + } + guard end > start else { continue } + if incomingScope == .semester { + courses.removeAll() + coverage.removeAll() + semesterEnd = end + scope = .semester + } + courses = courses.filter { + $0.value.startAt < start || $0.value.startAt >= end + } + for course in incoming.courses + where course.endAt > course.startAt && course.startAt >= start && course.startAt < end { + courses[course.id] = course + } + coverage = coverage.flatMap { range -> [WatchScheduleCoverage] in + guard range.start < end && range.end > start else { return [range] } + var pieces: [WatchScheduleCoverage] = [] + if range.start < start { + pieces.append( + .init(start: range.start, end: start, validThrough: range.validThrough)) + } + if range.end > end { + pieces.append( + .init(start: end, end: range.end, validThrough: range.validThrough)) + } + return pieces + } + coverage.append(.init(start: start, end: end, validThrough: incoming.validThrough)) + } + let metadata = newest.value + let snapshot = WatchScheduleSnapshot( + schemaVersion: metadata.schemaVersion, + generatedAtEpochMs: metadata.generatedAtEpochMs, + semesterStartEpochMs: metadata.semesterStartEpochMs, + currentWeekIndex: metadata.currentWeekIndex, + validThroughEpochMs: Int64( + (coverage.map(\.validThrough).max() ?? metadata.validThrough).timeIntervalSince1970 + * 1_000), + rangeStartEpochMs: Int64( + (coverage.map(\.start).min() ?? metadata.rangeStart).timeIntervalSince1970 * 1_000), + rangeEndEpochMs: Int64( + (coverage.map(\.end).max() ?? metadata.rangeEnd).timeIntervalSince1970 * 1_000), + timeZoneOffsetMinutes: metadata.timeZoneOffsetMinutes, + reminderMinutes: metadata.reminderMinutes, + courses: sorted(Array(courses.values)), + semesterEndEpochMs: semesterEnd.map { Int64($0.timeIntervalSince1970 * 1_000) }, + sourceRevision: metadata.sourceRevision + ) + return .init(snapshot: snapshot, coverage: coverage, semesterEnd: semesterEnd, scope: scope) + } + + static func sorted(_ courses: [WatchCourse]) -> [WatchCourse] { + courses.sorted(by: precedes) + } + + /// 时间相同时以 ID 排序,让合并结果和持久化索引使用同一稳定顺序。 + static func precedes(_ lhs: WatchCourse, _ rhs: WatchCourse) -> Bool { + if lhs.startAtEpochMs != rhs.startAtEpochMs { return lhs.startAtEpochMs < rhs.startAtEpochMs } + if lhs.endAtEpochMs != rhs.endAtEpochMs { return lhs.endAtEpochMs < rhs.endAtEpochMs } + return lhs.id < rhs.id + } + + static func isSorted(_ courses: [WatchCourse]) -> Bool { + zip(courses, courses.dropFirst()).allSatisfy { !precedes($0.1, $0.0) } + } + + private static func rank(_ scope: WatchScheduleScope) -> Int { + switch scope { + case .today: 0 + case .fourteenDays: 1 + case .semester: 2 + } + } +} + +enum WatchScheduleState: Equatable { + case noData, signedOut, expired, unconfirmed + case semesterUpcoming, semesterEnded, noMoreCourses + case todayFree, todayFinished, upcoming, imminent, ongoing +} + +/// 所有组件共享的课程选择。仅综合组件的临时预览允许替换 focus。 +struct WatchSchedulePresentation { + let date: Date + let resolved: WatchResolvedSchedule? + let calendar: Calendar + let current: WatchCourse? + let next: WatchCourse? + let focus: WatchCourse? + let state: WatchScheduleState + let isPreview: Bool + let todayCourses: [WatchCourse] + + init( + resolved: WatchResolvedSchedule?, at date: Date, signedOut: Bool = false, + preview: Bool = false + ) { + self.date = date + self.resolved = resolved + let calendar = Self.calendar(offsetMinutes: resolved?.snapshot.timeZoneOffsetMinutes) + self.calendar = calendar + let courses = resolved?.snapshot.courses ?? [] + current = courses.first { $0.startAt <= date && date < $0.endAt } + next = courses.first { $0.startAt > date } + isPreview = preview && current != nil && next != nil + let candidate = isPreview ? next : (current ?? next) + todayCourses = courses.filter { calendar.isDate($0.startAt, inSameDayAs: date) } + + guard let resolved else { + focus = nil + state = signedOut ? .signedOut : .noData + return + } + if let end = resolved.semesterEnd, date >= end { + focus = nil + state = .semesterEnded + } else if date > resolved.snapshot.validThrough { + focus = nil + state = .expired + } else if let start = resolved.snapshot.semesterStart, date < start { + focus = candidate + state = .semesterUpcoming + } else if !resolved.covers( + date, through: candidate?.startAt.addingTimeInterval(1) ?? date.addingTimeInterval(1), + at: date) + { + focus = nil + state = .unconfirmed + } else if let candidate { + focus = candidate + if candidate.startAt <= date { + state = .ongoing + } else if candidate.startAt.timeIntervalSince(date) <= 15 * 60 { + state = .imminent + } else if !calendar.isDate(candidate.startAt, inSameDayAs: date) { + state = todayCourses.isEmpty ? .todayFree : .todayFinished + } else { + state = .upcoming + } + } else { + focus = nil + if let end = resolved.semesterEnd, resolved.covers(date, through: end, at: date) { + state = .noMoreCourses + } else if let end = calendar.date( + byAdding: .day, value: 1, to: calendar.startOfDay(for: date)), + resolved.covers(date, through: end, at: date) + { + state = todayCourses.isEmpty ? .todayFree : .todayFinished + } else { + state = .unconfirmed + } + } + } + + static func calendar(offsetMinutes: Int?) -> Calendar { + WatchScheduleDate.calendar(offsetMinutes: offsetMinutes) + } + + var isCurrent: Bool { focus.map { $0.startAt <= date && date < $0.endAt } ?? false } + var startTimeText: String? { focus.map { clockText($0.startAt) } } + var endTimeText: String? { + guard let focus else { return nil } + let day = calendar.isDate(focus.startAt, inSameDayAs: focus.endAt) + ? "" : dayLabel(for: focus.endAt) + " " + return day + clockText(focus.endAt) + } + var timeRangeText: String? { + guard let startTimeText, let endTimeText else { return nil } + return startTimeText + "–" + endTimeText + } + /// 小尺寸只显示当前需要关注的一个时刻,日期由外围布局单独标注。 + var compactTime: (label: String, value: String)? { + guard let focus else { return nil } + return ( + watchLocalizedString(isCurrent ? "下课" : "上课"), + clockText(isCurrent ? focus.endAt : focus.startAt) + ) + } + /// 只有焦点课程正在进行时才显示进度;预览下一节不会沿用当前课程的进度。 + var courseProgress: Double? { + guard isCurrent, let focus else { return nil } + let duration = focus.endAt.timeIntervalSince(focus.startAt) + let elapsed = date.timeIntervalSince(focus.startAt) + guard duration.isFinite, duration > 0, elapsed.isFinite else { return nil } + return min(1, max(0, elapsed / duration)) + } + var title: String { + if isPreview { return watchLocalizedString("下一节") } + switch state { + case .noData: return watchLocalizedString("请先同步课表") + case .signedOut: return watchLocalizedString("请在手机登录") + case .expired: return watchLocalizedString("课表待更新") + case .unconfirmed: return watchLocalizedString("后续课表待同步") + case .semesterUpcoming: return watchLocalizedString("学期尚未开始") + case .semesterEnded: return watchLocalizedString("本学期结束,开心玩耍吧!") + case .noMoreCourses: return watchLocalizedString("本学期后续无课") + case .todayFree: return watchLocalizedString("今日无课") + case .todayFinished: return watchLocalizedString("今日已下课") + case .upcoming: return watchLocalizedString("下一节") + case .imminent: return watchLocalizedString("即将上课") + case .ongoing: return watchLocalizedString("正在上课") + } + } + var compactTitle: String { + state == .semesterEnded ? watchLocalizedString("学期结束啦") : title + } + var emptySymbol: String { + switch state { + case .noData, .unconfirmed, .expired: "arrow.triangle.2.circlepath" + case .signedOut: "person.crop.circle.badge.exclamationmark" + default: "cup.and.saucer.fill" + } + } + func dayLabel(for target: Date) -> String { + let days = + calendar.dateComponents( + [.day], from: calendar.startOfDay(for: date), to: calendar.startOfDay(for: target) + ).day ?? 0 + if days == 0 { return watchLocalizedString("今日") } + if days == 1 { return watchLocalizedString("明日") } + if days == 2 { return watchLocalizedString("后天") } + let formatter = DateFormatter() + formatter.locale = WatchWidgetShared.preferredLocale + formatter.calendar = calendar + formatter.timeZone = calendar.timeZone + formatter.setLocalizedDateFormatFromTemplate("MdEEE") + return formatter.string(from: target) + } + func clockText(_ target: Date) -> String { + WatchScheduleDate.clockText(target, timeZone: calendar.timeZone) + } + /// 小组件的紧凑日期:今天省略、明天直写,其余固定为月/日。 + func compactDayLabel(for target: Date) -> String { + let days = calendar.dateComponents( + [.day], from: calendar.startOfDay(for: date), to: calendar.startOfDay(for: target) + ).day ?? 0 + if days == 0 { return "" } + if days == 1 { return watchLocalizedString("明天") } + let formatter = DateFormatter() + formatter.locale = WatchWidgetShared.preferredLocale + formatter.calendar = calendar + formatter.timeZone = calendar.timeZone + formatter.dateFormat = "M/d" + return formatter.string(from: target) + } + func compactDateTimeText(for target: Date) -> String { + let day = compactDayLabel(for: target) + let time = clockText(target) + if day.isEmpty { return time } + if day == watchLocalizedString("明天") { + return watchLocalizedFormat("明天%@", time) + } + return day + " " + time + } + var location: String { + focus?.classroomText ?? watchLocalizedString("地点待定") + } + /// 小尺寸组件省略信远楼名,保留原有分区编号和教室号。 + var compactLocation: String { + WatchScheduleText.compactLocation(location) + } + /// 长方形与 App 课程卡片一致:位置后补充教师,考试则补充座位等备注。 + var locationSummary: String { + focus?.locationSummary(includingDetails: true, fallbackLocation: location)?.text ?? location + } + /// 日程概览始终汇总今天;下一次安排只作为次要信息展示。 + var summaryDate: Date { date } + var summaryCourses: [WatchCourse] { + todayCourses + } + var weekInterval: DateInterval { + calendar.dateInterval(of: .weekOfYear, for: summaryDate)! + } + var weekCourses: [WatchCourse] { + let interval = weekInterval + return (resolved?.snapshot.courses ?? []).filter { + $0.startAt >= interval.start && $0.startAt < interval.end + } + } + var summaryIsComplete: Bool { + let start = calendar.startOfDay(for: summaryDate) + return resolved?.covers( + start, through: calendar.date(byAdding: .day, value: 1, to: start)!, at: date) ?? false + } + var weekIsComplete: Bool { + let interval = weekInterval + return resolved?.covers(interval.start, through: interval.end, at: date) ?? false + } + + static func timelineDates( + resolved: WatchResolvedSchedule?, now: Date, previewExpiry: Date? = nil + ) -> [Date] { + let calendar = calendar(offsetMinutes: resolved?.snapshot.timeZoneOffsetMinutes) + let horizon = calendar.date(byAdding: .day, value: 2, to: calendar.startOfDay(for: now))! + var dates: Set = [now, horizon] + for course in resolved?.snapshot.courses ?? [] { + dates.formUnion([ + course.startAt.addingTimeInterval(-900), course.startAt, course.endAt, + ]) + // 使用有限数值绘制进度条,避免系统 timerInterval 布局在表盘渲染中 + // 产生 NaN 坐标。仅在上课期间每五分钟更新,起止时刻仍精确切换。 + let start = max(now, course.startAt) + let end = min(horizon, course.endAt) + guard start < end else { continue } + var progressDate = course.startAt.addingTimeInterval( + (floor(start.timeIntervalSince(course.startAt) / 300) + 1) * 300) + while progressDate < end { + dates.insert(progressDate) + progressDate = progressDate.addingTimeInterval(300) + } + } + for offset in 1...2 { + dates.insert( + calendar.date(byAdding: .day, value: offset, to: calendar.startOfDay(for: now))!) + } + for range in resolved?.coverage ?? [] { + dates.formUnion([range.start, range.end, range.validThrough.addingTimeInterval(1)]) + } + if let end = resolved?.semesterEnd { dates.insert(end) } + if let previewExpiry { dates.insert(previewExpiry) } + return dates.filter { $0 >= now && $0 <= horizon }.sorted() + } +} + +/// 概览只保留当前与下一项的详情,其余安排以可靠的日/周统计呈现。 +struct WatchOverviewSummary { + struct Counts { + let courses: Int + let exams: Int + let experiments: Int + var total: Int { courses + exams + experiments } + + init(_ items: [WatchCourse]) { + var examCount = 0 + var experimentCount = 0 + for item in items { + if item.isExam { examCount += 1 } + else if item.isExperiment { experimentCount += 1 } + } + exams = examCount + experiments = experimentCount + courses = items.count - exams - experiments + } + } + + struct Day { + let all: Counts + let remaining: Counts + let completedCount: Int + /// 只有末项时间未出现在两张卡片中时,才在摘要里补充今日结束时间。 + let additionalEndTime: Date? + } + + struct Week { + let remainingDays: Int + let upcoming: Counts + } + + let current: WatchCourse? + let next: WatchCourse? + let today: Day? + let week: Week? + + init(_ presentation: WatchSchedulePresentation) { + let usable = ![.noData, .signedOut, .expired, .semesterEnded].contains(presentation.state) + let current = usable && presentation.isCurrent ? presentation.focus : nil + let next = presentation.next.flatMap { candidate -> WatchCourse? in + guard usable else { return nil } + // 正在上课时也需要确认中间覆盖完整,不能把旧缓存中的某节课 + // 当成下一节。当前无课时复用共享状态已选中的焦点课程。 + guard presentation.focus?.id == candidate.id + || presentation.resolved?.covers( + presentation.date, through: candidate.startAt.addingTimeInterval(1), + at: presentation.date) == true + else { return nil } + return candidate + } + self.current = current + self.next = next + + if usable && presentation.summaryIsComplete { + let all = presentation.summaryCourses + let remaining = all.filter { $0.endAt > presentation.date } + let lastEnd = remaining.map(\.endAt).max() + let displayedEnds = [current?.endAt, next?.endAt].compactMap { $0 } + today = Day( + all: Counts(all), remaining: Counts(remaining), + completedCount: all.count - remaining.count, + additionalEndTime: lastEnd.flatMap { displayedEnds.contains($0) ? nil : $0 }) + } else { + today = nil + } + + // 本周这里只展示未来安排;已过去的日期未同步,不影响这部分统计。 + if usable && presentation.resolved?.covers( + presentation.date, through: presentation.weekInterval.end, at: presentation.date) == true + { + let remaining = presentation.weekCourses.filter { $0.endAt > presentation.date } + week = Week( + remainingDays: Set(remaining.map { + presentation.calendar.startOfDay(for: $0.startAt) + }).count, + upcoming: Counts(remaining.filter { $0.startAt > presentation.date })) + } else { + week = nil + } + } +} + +/// 无正文的语言消息不参与课表排序;旧版课表只允许在首次迁移前安装。 +enum WatchScheduleStateOrder { + static func accepts( + revision: Int?, generation: String?, installedRevision: Int, + installedGeneration: String?, carriesSchedule: Bool + ) -> Bool { + guard carriesSchedule else { return true } + guard let revision else { return installedRevision == 0 } + guard revision >= installedRevision else { return false } + if let installedGeneration { + guard let generation else { return false } + if revision == installedRevision && generation != installedGeneration { return false } + } + return true + } +} diff --git a/watchOS/Shared/WatchSyncSupport.swift b/watchOS/Shared/WatchSyncSupport.swift new file mode 100644 index 00000000..8946f00a --- /dev/null +++ b/watchOS/Shared/WatchSyncSupport.swift @@ -0,0 +1,147 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import Foundation + +/// iPhone、Watch App 和 Widget 共用的协议定义,不依赖任何界面框架。 +enum WatchSyncProtocol { + static let supportedSchemaVersions = 1...4 + + /// 分页必须向前推进;当天与近 14 天回复必须一次完整交付。 + static func acceptsPagination( + scope: WatchScheduleScope, offset: Int, nextOffset: Int, hasMore: Bool + ) -> Bool { + guard offset >= 0, nextOffset >= 0 else { return false } + // 末页可以为空,但不能用回退的偏移确认前面的分块已经收齐。 + if scope == .semester && nextOffset < offset { return false } + return !hasMore || (scope == .semester && nextOffset > offset) + } + + enum Key { + static let scheduleJSON = "scheduleJSON" + static let requestSchedule = "requestSchedule" + static let scope = "scheduleScope" + static let offset = "scheduleOffset" + static let nextOffset = "scheduleNextOffset" + static let hasMore = "scheduleHasMore" + static let preferredLanguage = "preferredLanguage" + static let scheduleVersion = "scheduleVersion" + static let scheduleUnchanged = "scheduleUnchanged" + static let scheduleCleared = "scheduleCleared" + static let signedOut = "signedOut" + static let stateRevision = "stateRevision" + static let accountGeneration = "accountGeneration" + static let messageType = "messageType" + static let refreshID = "refreshID" + static let requestID = "requestID" + } + + enum MessageType { + static let request = "scheduleRequest" + static let response = "scheduleResponse" + } +} + +/// 三阶段同步顺序与日期范围;本地化阶段标题由 Watch 模型提供。 +enum WatchScheduleScope: String, CaseIterable { + case today + case fourteenDays + case semester + + var next: Self? { + switch self { + case .today: .fourteenDays + case .fourteenDays: .semester + case .semester: nil + } + } +} + +/// 协议值、BCP-47 标识与资源目录的唯一映射。显式文字脚本优先于地区。 +enum WatchLanguage: String, CaseIterable { + case simplifiedChinese = "zh_CN" + case traditionalChinese = "zh_TW" + case english = "en_US" + + init?(identifier: String?) { + guard let identifier else { return nil } + let parts = identifier.trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "-", with: "_").lowercased().split(separator: "_") + switch parts.first { + case "en": + self = .english + case "zh": + if parts.contains("hant") { + self = .traditionalChinese + } else if parts.contains("hans") { + self = .simplifiedChinese + } else { + self = parts.contains(where: { $0 == "tw" || $0 == "hk" || $0 == "mo" }) + ? .traditionalChinese : .simplifiedChinese + } + default: + return nil + } + } + + var resourceName: String { + switch self { + case .simplifiedChinese: "zh-Hans" + case .traditionalChinese: "zh-Hant" + case .english: "en" + } + } + + var locale: Locale { Locale(identifier: resourceName) } +} + +/// 时间戳和固定 24 小时显示共用一组纯函数,时区始终由调用者指定。 +enum WatchScheduleDate { + static func date(fromEpochMilliseconds value: Int64) -> Date { + Date(timeIntervalSince1970: TimeInterval(value) / 1_000) + } + + static func epochMilliseconds(for date: Date) -> Int64 { + Int64((date.timeIntervalSince1970 * 1_000).rounded()) + } + + static func calendar(offsetMinutes: Int?) -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.firstWeekday = 2 + calendar.minimumDaysInFirstWeek = 4 + // 先限制分钟数,避免协议中的异常整数在乘以 60 时溢出。 + if let offsetMinutes, (-1_080...1_080).contains(offsetMinutes), + let timeZone = TimeZone(secondsFromGMT: offsetMinutes * 60) { + calendar.timeZone = timeZone + } + return calendar + } + + @available(iOS 15.0, watchOS 8.0, macOS 12.0, *) + static func clockText(_ date: Date, timeZone: TimeZone = .current) -> String { + date.formatted( + Date.VerbatimFormatStyle( + format: "\(hour: .twoDigits(clock: .twentyFourHour, hourCycle: .zeroBased)):\(minute: .twoDigits)", + timeZone: timeZone, + calendar: Calendar(identifier: .gregorian) + ) + ) + } +} + +enum WatchScheduleText { + static func nonempty(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { return nil } + return value + } + + static func singleLine(_ value: String?) -> String? { + nonempty(value)?.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ") + } + + static func compactLocation(_ value: String) -> String { + value.replacingOccurrences(of: "信远", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/watchOS/Shared/WatchWidgetDesignTokens.swift b/watchOS/Shared/WatchWidgetDesignTokens.swift new file mode 100644 index 00000000..e948fc6e --- /dev/null +++ b/watchOS/Shared/WatchWidgetDesignTokens.swift @@ -0,0 +1,15 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import SwiftUI + +/// 实际小组件共用的文字层级;截图式教学预览由原图决定字号。 +enum WatchWidgetDesignTokens { + static let circularPrimary = Font.system(size: 12, weight: .semibold) + static let circularSecondary = Font.system(size: 8, weight: .medium) + static let circularProgressHeading = Font.system(size: 7, weight: .medium) + static let circularSpacing: CGFloat = 2 + static let cornerPrimary = Font.system(size: 12, weight: .bold, design: .rounded) + static let cornerTime = Font.system(size: 10, weight: .regular, design: .rounded) + static let rectangularInfo = Font.system(size: 16, weight: .regular) +} diff --git a/watchOS/Shared/WatchWidgetShared.swift b/watchOS/Shared/WatchWidgetShared.swift new file mode 100644 index 00000000..8a6bff61 --- /dev/null +++ b/watchOS/Shared/WatchWidgetShared.swift @@ -0,0 +1,353 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import Foundation +import WidgetKit + +/// 所有小组件都进入概览;旧时间线中的课程和日期链接也兼容到同一入口。 +enum WatchWidgetDestination: Equatable { + case overview + + private static let scheme = "xdyou-watch" + + var url: URL { URL(string: "\(Self.scheme)://overview")! } + + init?(url: URL) { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + components.scheme == Self.scheme, + components.path.isEmpty, + let host = components.host, ["overview", "course", "day"].contains(host) + else { return nil } + self = .overview + } +} + +/// Watch App 私有持久化缓存的稳定键名。 +/// +/// 课表正文的三个 App Group 键仍由 `WatchWidgetShared` 管理;这里仅保存 +/// Widget 不直接读取、但 Watch App 启动性能需要的索引和布局信息。集中定义 +/// 可以避免 Store 与 View 各自维护裸字符串。 +enum WatchPersistentCacheKey { + static let installedSemesterVersion = + "TraintimeWatchInstalledSemesterScheduleVersion" + static let scheduleRenderIndex = "XDYouWatchScheduleRenderCache" + static let dayCourseLayout = "XDYouWatchDayCourseLayoutCache" + /// 清空课表后使尚在后台编码的派生缓存失效,防止旧任务重新写回。 + static let invalidationGeneration = "XDYouWatchCacheInvalidationGeneration" + static let completedOnboarding = "XDYouWatchCompletedOnboardingV1" +} + +/// Watch App 私有 Codable 缓存的统一 JSON 读写入口。 +/// +/// 调用方继续负责 schema、来源签名和业务完整性校验;本类型只消除重复的 +/// `JSONEncoder/JSONDecoder + UserDefaults` 模板,并保持写入为单个 Data 值。 +enum WatchCacheCoding { + /// 把可编码值转换成可跨任务传递、一次写入 Defaults 的 Data。 + static func encode(_ value: Value) throws -> Data { + try JSONEncoder().encode(value) + } + + /// 派生缓存共用后台编码;父任务取消时停止尚未开始的旧编码任务。 + static func encodeInBackground(_ value: Value) async throws -> Data { + try Task.checkCancellation() + let task = Task.detached(priority: .utility) { + try Task.checkCancellation() + let data = try encode(value) + try Task.checkCancellation() + return data + } + return try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() + } + } + + /// 解码 WatchConnectivity 与持久化层共用的 UTF-8 JSON 字符串。 + static func decode( + _ type: Value.Type, + fromJSON json: String + ) throws -> Value { + try JSONDecoder().decode(type, from: Data(json.utf8)) + } + + /// 将 Codable 值编码成同步协议使用的 UTF-8 JSON 字符串。 + static func encodeJSON(_ value: Value) throws -> String { + String(decoding: try encode(value), as: UTF8.self) + } + + /// 从指定 Defaults 读取并解码;键不存在时返回 `nil`。 + static func load( + _ type: Value.Type, + key: String, + defaults: UserDefaults = .standard + ) throws -> Value? { + guard let data = defaults.data(forKey: key) else { return nil } + return try JSONDecoder().decode(type, from: data) + } + + /// 编码并覆盖指定键;编码失败时不会改写旧缓存。 + static func persist( + _ value: Value, + key: String, + defaults: UserDefaults = .standard + ) throws { + let data = try encode(value) + persist(data, key: key, defaults: defaults) + } + + /// 写入已经在后台完成编码的数据,不在调用线程重复执行 JSON 工作。 + static func persist( + _ data: Data, + key: String, + defaults: UserDefaults = .standard + ) { + defaults.set(data, forKey: key) + } +} + +/// App 与 Widget 从通信或磁盘恢复快照时采用完全相同的 schema 校验。 +enum WatchScheduleCoding { + static func decode(_ json: String) throws -> WatchScheduleSnapshot { + let snapshot = try WatchCacheCoding.decode(WatchScheduleSnapshot.self, fromJSON: json) + guard WatchSyncProtocol.supportedSchemaVersions.contains(snapshot.schemaVersion) else { + throw WatchScheduleDataError.unsupportedSchema(snapshot.schemaVersion) + } + return snapshot + } +} + +/// 手表 App 与 Widget Extension 的共享存储入口。 +/// +/// 两个进程不能直接共享 `UserDefaults.standard`,因此课表需要同时写入 +/// App Group。所有缓存键统一定义在此处,防止 App 与小组件使用不同键名。 +enum WatchWidgetShared { + /// 必须与两个 target 的 entitlements 中的 App Group 完全一致。 + static let appGroupIdentifier = "group.xyz.superbart.xdyou" + + /// WidgetKit 注册和刷新时间线时使用的唯一类型标识。 + static let widgetKind = "TraintimeScheduleWidget" + + /// 专用表盘组件各自使用独立 kind,系统组件库才能把它们作为不同选项展示。 + static let courseNameWidgetKind = "TraintimeCourseNameWidget" + static let courseTimeLocationWidgetKind = "TraintimeCourseTimeLocationWidget" + static let todayScheduleWidgetKind = "TraintimeTodayScheduleWidget" + + /// 课表或语言变化时需要一起刷新的全部 Widget 类型。 + static let allWidgetKinds = [ + widgetKind, + courseNameWidgetKind, + courseTimeLocationWidgetKind, + todayScheduleWidgetKind, + ] + + /// 三阶段同步各自独立保存,只有阶段完整完成后才覆盖对应缓存。 + static let semesterCacheKey = "watchScheduleSnapshot" + static let fourteenDayCacheKey = "watchScheduleSnapshot.fourteenDays" + static let todayCacheKey = "watchScheduleSnapshot.today" + + /// 小组件交互按钮使用的轻量状态键。 + static let selectedCurrentCourseKey = "watchWidget.selectedCurrentCourse" + static let previewExpiresKey = "watchWidget.previewExpires" + static let signedOutKey = "watchSchedule.signedOut" + static let stateRevisionKey = "watchSchedule.stateRevision" + static let accountGenerationKey = "watchSchedule.accountGeneration" + + /// 手机同步过来的实际语言。App 与 Widget 共用,避免两个界面语言不一致。 + static let preferredLanguageKey = "watchPreferredLanguage" + + /// Watch App 与 Widget 当前共同支持的课表 schema。 + static let supportedScheduleSchemaVersions = WatchSyncProtocol.supportedSchemaVersions + + /// 稳定的缓存读取顺序;最终选择由修订号与明确覆盖范围共同决定。 + static let scheduleCacheScopesByPriority: [WatchScheduleScope] = [ + .semester, + .fourteenDays, + .today, + ] + + /// App Group 对应的共享 UserDefaults。 + /// + /// 返回可选值是因为签名或 entitlement 配置错误时系统可能无法创建 suite。 + static var defaults: UserDefaults? { + UserDefaults(suiteName: appGroupIdentifier) + } + + /// 当前手机指定的语言;首次同步前回退到手表系统语言。 + static var preferredLanguageIdentifier: String { + if let stored = (defaults ?? .standard).string(forKey: preferredLanguageKey), + let normalized = normalizedPreferredLanguage(stored) + { + return normalized + } + + let systemLanguage = + Locale.preferredLanguages.first ?? Locale.current.identifier + return normalizedPreferredLanguage(systemLanguage) ?? "zh_CN" + } + + /// SwiftUI 和 String Catalog 使用的标准 Locale。 + static var preferredLocale: Locale { + locale(for: preferredLanguageIdentifier) + } + + /// 将不同平台的语言代码收敛为与手机设置一致的三个协议值。 + static func normalizedPreferredLanguage(_ value: String) -> String? { + WatchLanguage(identifier: value)?.rawValue + } + + /// 将协议语言值映射为 String Catalog 能正确匹配的 Locale。 + static func locale(for languageIdentifier: String) -> Locale { + (WatchLanguage(identifier: languageIdentifier) ?? .simplifiedChinese).locale + } + + /// 保存新语言并刷新 Widget;返回值表示语言是否实际发生变化。 + @discardableResult + static func updatePreferredLanguage( + _ value: String, + in defaults: UserDefaults? = Self.defaults, + reloadWidgets: () -> Void = Self.reloadWidgetTimelines + ) -> Bool { + guard let normalized = normalizedPreferredLanguage(value), + let defaults + else { + return false + } + + let changed = + defaults.string(forKey: preferredLanguageKey) != normalized + defaults.set(normalized, forKey: preferredLanguageKey) + if changed { + reloadWidgets() + } + return changed + } + + /// 将同步范围映射为稳定的缓存键。 + static func cacheKey(for scope: WatchScheduleScope) -> String { + switch scope { + case .today: + todayCacheKey + case .fourteenDays: + fourteenDayCacheKey + case .semester: + semesterCacheKey + } + } + + /// 保存一个已经完整完成的同步阶段,并通知所有同类组件刷新。 + /// + /// 调用方必须确保 JSON 已经完成解码校验;该函数只负责跨进程持久化。 + static func persist(json: String, scope: WatchScheduleScope) { + guard let defaults else { return } + persist( + json: json, + key: cacheKey(for: scope), + defaults: defaults + ) + reloadWidgetTimelines() + } + + /// 读取当前最适合展示的课表快照。 + /// + /// 按修订号合并同学期的完整阶段;各段有效期由展示层分别判断。 + static func loadPreferredSnapshot( + now: Date = Date() + ) -> WatchScheduleSnapshot? { + loadResolvedSchedule()?.snapshot + } + + static func loadResolvedSchedule() -> WatchResolvedSchedule? { + guard let defaults else { return nil } + return WatchScheduleResolver.resolve(loadCachedSnapshots(from: defaults)) + } + + static func clearSchedule(in defaults: UserDefaults) { + defaults.set(defaults.integer(forKey: WatchPersistentCacheKey.invalidationGeneration) &+ 1, + forKey: WatchPersistentCacheKey.invalidationGeneration) + for scope in scheduleCacheScopesByPriority { + defaults.removeObject(forKey: cacheKey(for: scope)) + } + for key in [selectedCurrentCourseKey, previewExpiresKey, + WatchPersistentCacheKey.installedSemesterVersion, + WatchPersistentCacheKey.scheduleRenderIndex, + WatchPersistentCacheKey.dayCourseLayout] { + defaults.removeObject(forKey: key) + } + } + + /// 向 App Group 写入已验证的快照正文。 + private static func persist( + json: String, + key: String, + defaults: UserDefaults + ) { + defaults.set(json, forKey: key) + } + + /// 逐个读取三种缓存,坏数据只影响自身,不会阻断其他阶段的回退。 + private static func loadCachedSnapshots( + from defaults: UserDefaults + ) -> [WatchScheduleScope: WatchScheduleSnapshot] { + var snapshots = [WatchScheduleScope: WatchScheduleSnapshot]() + + for scope in scheduleCacheScopesByPriority { + guard let json = defaults.string(forKey: cacheKey(for: scope)), + let snapshot = decodeSnapshot(from: json) + else { + continue + } + snapshots[scope] = snapshot + } + return snapshots + } + + /// 解码并校验 schema,避免扩展因未来不兼容数据而崩溃。 + private static func decodeSnapshot( + from json: String + ) -> WatchScheduleSnapshot? { + try? WatchScheduleCoding.decode(json) + } + + /// 只刷新本项目的课程组件,避免影响其他 Widget。 + static func reloadWidgetTimelines() { + for kind in allWidgetKinds { + WidgetCenter.shared.reloadTimelines(ofKind: kind) + } + } +} + +/// 使用手机同步语言从指定 `.lproj` 中读取显式字符串。 +/// +/// `String(localized:locale:)` 的 `locale` 主要参与插值格式化,Bundle 仍可能 +/// 按手表系统首选语言选择资源。因此手机设置为英语、而手表系统是中文时, +/// 目录标题仍会返回中文。这里显式选择 `en.lproj` 或 `zh-Hant.lproj`; +/// 简体中文是 String Catalog 的源语言,直接返回中文键即可。 +/// 经此封装读取的目录条目应设为手动管理;Xcode 无法从动态 `key` +/// 追踪调用处,否则仍在使用的翻译会在构建后被误标为 STALE。 +func watchLocalizedString( + _ key: String, + languageIdentifier: String = WatchWidgetShared.preferredLanguageIdentifier +) -> String { + let language = WatchLanguage(identifier: languageIdentifier) ?? .simplifiedChinese + guard language != .simplifiedChinese, + let bundle = WatchLocalizationResources.bundles[language] + else { return key } + return bundle.localizedString(forKey: key, value: key, table: nil) +} + +/// 文案与格式化参数均使用手机指定语言,避免混用手表系统 Locale。 +func watchLocalizedFormat(_ key: String, _ arguments: CVarArg...) -> String { + String(format: watchLocalizedString(key), locale: WatchWidgetShared.preferredLocale, + arguments: arguments) +} + +private enum WatchLocalizationResources { + /// 资源包最多解析三次;切换语言只切换索引,不缓存已翻译的最终文案。 + static let bundles: [WatchLanguage: Bundle] = Dictionary( + uniqueKeysWithValues: WatchLanguage.allCases.compactMap { language in + guard let path = Bundle.main.path(forResource: language.resourceName, ofType: "lproj"), + let bundle = Bundle(path: path) else { return nil } + return (language, bundle) + }) +} diff --git a/watchOS/Storage/DayCourseLayoutCache.swift b/watchOS/Storage/DayCourseLayoutCache.swift new file mode 100644 index 00000000..df7d8b9a --- /dev/null +++ b/watchOS/Storage/DayCourseLayoutCache.swift @@ -0,0 +1,260 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import Foundation + +/// 当前日期的卡片布局采样。 +/// +/// 只记录不随滚动位置变化的高度,表冠每帧不需要重新传递 frame。 +struct DayCourseLayoutMetrics: Equatable { + var cardHeights: [String: CGFloat] = [:] +} + +/// 日视图卡片高度持久化格式。 +private struct PersistedDayCourseLayoutCache: Codable, Sendable { + let schemaVersion: Int + let signature: String + let cardHeights: [String: Double] +} + +enum DayCourseLayoutCacheConfiguration { + static let schemaVersion = 1 + static let persistenceDelayNanoseconds: UInt64 = 1_500_000_000 +} + +/// 保存日视图已经测量的卡片高度,但不发布变化,避免重绘父页面。 +/// +/// 相邻页在进入屏幕前就完成采样;横向跨页时只切换当前日期指针并复用采样。 +/// 缓存是普通引用状态,不会让表冠每个像素都触发父页面 +/// 更新。高度按当前快照修订、语言、内容宽度及动态字体环境持久化;任何 +/// 条件变化都舍弃旧值,避免局部同步或字号变化后仍用旧高度计算位移。 +@MainActor +final class DayCourseLayoutTracker { + private let defaults: UserDefaults + private var metrics = DayCourseLayoutMetrics() + // 平均高度只随采样变化,表冠和触摸每帧读取时不遍历整学期的测量结果。 + private var averageMeasuredHeight: CGFloat? + private var activeSignature: String? + private var persistenceTask: Task? + private var persistenceDirty = false + private var persistenceSuspended = false + private var cacheGeneration = 0 + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func update(metrics: DayCourseLayoutMetrics) { + var changed = false + for (courseID, height) in metrics.cardHeights { + guard height.isFinite, height > 0 else { continue } + let normalizedHeight = max(1, height) + if let oldHeight = self.metrics.cardHeights[courseID], + abs(oldHeight - normalizedHeight) <= 0.25 + { + continue + } + self.metrics.cardHeights[courseID] = normalizedHeight + changed = true + } + if changed { + refreshAverageMeasuredHeight() + persistenceDirty = true + schedulePersistence() + } + } + + /// 按当前课表和布局环境恢复磁盘缓存;签名相同的重复调用没有开销。 + func configure(signature: String) { + let generation = defaults.integer(forKey: WatchPersistentCacheKey.invalidationGeneration) + guard activeSignature != signature || cacheGeneration != generation else { return } + cacheGeneration = generation + persistenceTask?.cancel() + persistenceTask = nil + persistenceDirty = false + activeSignature = signature + metrics = DayCourseLayoutMetrics() + averageMeasuredHeight = nil + + guard + let cache = try? WatchCacheCoding.load( + PersistedDayCourseLayoutCache.self, + key: WatchPersistentCacheKey.dayCourseLayout, + defaults: defaults + ), + cache.schemaVersion + == DayCourseLayoutCacheConfiguration.schemaVersion, + cache.signature == signature + else { + return + } + metrics.cardHeights = cache.cardHeights.filter { $0.value.isFinite && $0.value > 0 } + .mapValues { value in + CGFloat(value) + } + refreshAverageMeasuredHeight() + } + + /// 交互期间不排队新的编码和写盘;已启动的后台编码不能提交旧结果。 + func suspendPersistence() { + persistenceSuspended = true + persistenceTask?.cancel() + persistenceTask = nil + } + + /// 页面停止交互后再补写尚未落盘的测量值。 + func resumePersistence() { + persistenceSuspended = false + guard persistenceDirty else { return } + schedulePersistence() + } + + /// 合并相邻三页测量结果后延迟写盘,连续翻页期间绝不执行磁盘编码。 + private func schedulePersistence() { + guard !persistenceSuspended else { return } + persistenceTask?.cancel() + persistenceTask = Task { @MainActor [weak self] in + try? await Task.sleep( + nanoseconds: DayCourseLayoutCacheConfiguration + .persistenceDelayNanoseconds + ) + guard !Task.isCancelled else { return } + await self?.persist() + } + } + + /// 在后台编码高度缓存,回到主线程后再原子写入最新签名的数据。 + private func persist() async { + guard let activeSignature, !metrics.cardHeights.isEmpty else { return } + let cache = PersistedDayCourseLayoutCache( + schemaVersion: DayCourseLayoutCacheConfiguration.schemaVersion, + signature: activeSignature, + cardHeights: metrics.cardHeights.mapValues { value in + Double(value) + } + ) + do { + let data = try await WatchCacheCoding.encodeInBackground(cache) + guard !Task.isCancelled, + !persistenceSuspended, + self.activeSignature == activeSignature, + cacheGeneration + == defaults.integer(forKey: WatchPersistentCacheKey.invalidationGeneration) + else { return } + WatchCacheCoding.persist( + data, + key: WatchPersistentCacheKey.dayCourseLayout, + defaults: defaults + ) + } catch { + return + } + persistenceDirty = false + persistenceTask = nil + } + + deinit { persistenceTask?.cancel() } + + /// 将连续的“课程索引”插值成内容纵向位移。 + /// + /// 使用每张卡片的真实高度而不是猜测固定高度,课程名换行、 + /// 考试座位等内容导致卡片高度不同时也不会在提交下一项时跳动。 + func contentOffset( + for position: Double, + courses: [WatchCourse], + spacing: CGFloat + ) -> CGFloat { + guard courses.count > 1 else { return 0 } + let boundedPosition = min( + Double(courses.count - 1), + max(0, position) + ) + let lowerIndex = Int(floor(boundedPosition)) + let upperIndex = min(courses.count - 1, lowerIndex + 1) + let fraction = CGFloat(boundedPosition - Double(lowerIndex)) + let fallbackHeight = averageMeasuredHeight ?? 72 + let offsets = courseTopOffsets( + courses: courses, + spacing: spacing, + fallbackHeight: fallbackHeight + ) + return offsets[lowerIndex] + + (offsets[upperIndex] - offsets[lowerIndex]) * fraction + } + + /// 把统一的内容纵向偏移反算成连续课程位置。 + /// + /// 手指和表冠共用这一坐标,放手后表冠从屏幕当前所见位置继续移动。 + func position( + forContentOffset contentOffset: CGFloat, + courses: [WatchCourse], + spacing: CGFloat + ) -> Double { + guard courses.count > 1 else { return 0 } + let offsets = courseTopOffsets( + courses: courses, + spacing: spacing, + fallbackHeight: averageMeasuredHeight ?? 72 + ) + guard let first = offsets.first, + let last = offsets.last + else { + return 0 + } + if contentOffset >= first { return 0 } + if contentOffset <= last { return Double(courses.count - 1) } + + for lowerIndex in 0..<(offsets.count - 1) { + let upperOffset = offsets[lowerIndex] + let lowerOffset = offsets[lowerIndex + 1] + guard contentOffset <= upperOffset, + contentOffset >= lowerOffset + else { + continue + } + let distance = upperOffset - lowerOffset + let fraction = + distance > 0 + ? (upperOffset - contentOffset) / distance + : 0 + return Double(lowerIndex) + Double(fraction) + } + return 0 + } + + /// 当前卡片栈的真实内容高度,用于触摸结束后的底边贴合。 + func contentHeight( + courses: [WatchCourse], + spacing: CGFloat + ) -> CGFloat { + guard !courses.isEmpty else { return 0 } + let fallbackHeight = averageMeasuredHeight ?? 72 + let cardHeight = courses.reduce(CGFloat.zero) { partial, course in + partial + (metrics.cardHeights[course.id] ?? fallbackHeight) + } + return cardHeight + CGFloat(max(0, courses.count - 1)) * spacing + } + + /// 以第一张卡片为原点,计算每张卡片顶边对应的内容偏移。 + private func courseTopOffsets( + courses: [WatchCourse], + spacing: CGFloat, + fallbackHeight: CGFloat + ) -> [CGFloat] { + var result = [CGFloat]() + result.reserveCapacity(courses.count) + var accumulatedHeight: CGFloat = 0 + for course in courses { + result.append(-accumulatedHeight) + accumulatedHeight += (metrics.cardHeights[course.id] ?? fallbackHeight) + spacing + } + return result + } + + /// 首帧采样未完成时使用已有卡片的平均高度作为短暂回退。 + private func refreshAverageMeasuredHeight() { + averageMeasuredHeight = metrics.cardHeights.isEmpty + ? nil + : metrics.cardHeights.values.reduce(0, +) / CGFloat(metrics.cardHeights.count) + } +} diff --git a/watchOS/Storage/WatchScheduleStore.swift b/watchOS/Storage/WatchScheduleStore.swift new file mode 100644 index 00000000..b45f43fd --- /dev/null +++ b/watchOS/Storage/WatchScheduleStore.swift @@ -0,0 +1,1251 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import Combine +import Foundation + +/// 课程列表预先生成的自然日分组。 +/// +/// 这份索引在课表安装时一次性构造,进入课程列表页面时只负责渲染和定位, +/// 避免第一次切换页面时才对整学期课程做分组、排序而产生明显卡顿。 +struct WatchCourseDayGroup: Identifiable { + let date: Date + let courses: [WatchCourse] + + var id: Date { date } +} + +/// Watch 各课表视图共用的派生缓存格式。 +/// +/// 这里保存的是“课程 ID 如何排序、如何按自然日分组、月视图五段标记引用哪 +/// 门课”,而不是 SwiftUI 视图或位图。课表语义版本仍只由 iPhone 计算; +/// Watch 端的来源字段只用于确认落盘索引与当前原始快照属于同一次安装,避免 +/// App 在写盘中途退出后错误复用一份旧索引。 +private enum WatchScheduleRenderCacheLayout { + static let schemaVersion = 2 + static let periodRanges = [ + 1...2, + 3...4, + 5...6, + 7...8, + 9...10, + ] +} + +/// 派生缓存对应的原始快照身份;不参与课表是否变化的业务判断。 +private struct PersistedScheduleRenderSource: Codable, Equatable, Sendable { + let snapshotSchemaVersion: Int + let generatedAtEpochMs: Int64 + let sourceRevision: Int64? + let calendarIdentifier: String + let timeZoneIdentifier: String + let rangeStartEpochMs: Int64? + let rangeEndEpochMs: Int64? + let courseCount: Int +} + +/// 单个自然日的持久化索引。 +private struct PersistedScheduleRenderDay: Codable, Sendable { + let dayStartEpochMs: Int64 + let courseIDs: [String] + /// 固定对应 1–2、3–4、5–6、7–8、9–10 节。 + let periodCourseIDs: [String?] +} + +/// 日、周、月和课程列表共同复用的轻量派生缓存。 +private struct PersistedScheduleRenderCache: Codable, Sendable { + let schemaVersion: Int + let source: PersistedScheduleRenderSource + let sortedCourseIDs: [String] + let days: [PersistedScheduleRenderDay] + let courseListReferenceDayEpochMs: Int64 + let courseListInitialDayEpochMs: Int64? +} + +/// 通过完整性校验后,可直接安装到内存中的派生索引。 +private struct RestoredScheduleRenderIndex { + let coursesByID: [String: WatchCourse] + let coursesByDay: [Date: [WatchCourse]] + let periodCourseIDsByDay: [Date: [String?]] +} + +/// 课程列表启动位置及其是否需要在稍后重新落盘。 +private struct RestoredCourseListPosition { + let date: Date? + let needsPersistence: Bool +} + +/// 手表界面的课表状态中心。 +/// +/// 该对象只在主线程修改可观察状态;同步过程收到的数据必须先完整解码, +/// 成功后才替换当前页面并写入缓存,因此半包或坏数据不会污染旧缓存。 +@MainActor +final class WatchScheduleStore: ObservableObject { + @Published private(set) var snapshot: WatchScheduleSnapshot? + @Published private(set) var preferredLanguageIdentifier: String + @Published private(set) var syncError: String? + @Published private(set) var loadingScope: WatchScheduleScope? + @Published private(set) var loadedScope: WatchScheduleScope? + @Published private(set) var isRefreshing = false + @Published private(set) var completedRefreshCount = 0 + @Published private(set) var isAwaitingLaunchSyncReply = false + @Published private(set) var launchSyncTimedOut = false + @Published private(set) var courseListGroups: [WatchCourseDayGroup] = [] + @Published private(set) var courseListInitialDate: Date? + /// 派生索引安装后递增;月视图据此只刷新当前三页的颜色标记。 + @Published private(set) var renderCacheRevision = 0 + private(set) var installedScheduleVersion: String? + + /// 手表 App 自身的标准缓存,用于不依赖 Widget 的离线恢复。 + private let defaults: UserDefaults + private let sharedDefaults: UserDefaults? + private let reloadWidgets: () -> Void + + /// 每个同步阶段保留一份独立快照,方便按有效期回退。 + private var cachedSnapshots: [ + WatchScheduleScope: WatchScheduleSnapshot + ] = [:] + /// 覆盖范围合并只在快照安装时执行;概览时钟更新不重复排序整学期。 + private var resolvedPresentation: WatchResolvedSchedule? + + /// 整学期数据可能分多个消息传输,先按课程 ID 合并到临时缓冲区。 + private var semesterTransfer = WatchSemesterTransfer() + + /// 当前页面使用的稳定排序结果,随快照安装同步更新。 + private var sortedVisibleCourses: [WatchCourse] = [] + + /// 日视图三页预加载使用的自然日索引。 + /// + /// 连续翻页期间会同时读取前一天、当天和后一天。提前建立索引后无需在 + /// 每一帧对整学期课表执行三次过滤,真机上的页面换底会更稳定。 + private var coursesByDay: [Date: [WatchCourse]] = [:] + + /// 课程 ID 到当前快照模型的映射;恢复持久化索引时无需复制完整课程。 + private var coursesByID: [String: WatchCourse] = [:] + + /// 月视图每个自然日的五段课程 ID,绘制时再读取课程颜色。 + private var periodCourseIDsByDay: [Date: [String?]] = [:] + + /// 月视图的日期模型和课程标记由独立缓存管理,Store 不持有其组装细节。 + private var monthCalendarCache = MonthCalendarCache() + private var indexedCalendar = Calendar.current + + /// 安装派生索引时一次算好的教学日期。 + /// + /// 该结果只会在课表索引变化时改变,因此与索引同时更新;欢迎页和章节 + /// 切换时只做常量时间读取。 + private var preparedOnboardingDate: Date? + + /// 当前派生索引覆盖的课程数量,用于 O(1) 完整性检查。 + private var indexedCourseCount = 0 + + /// 启动时若派生缓存缺失,先在内存中建立以保证页面可用;待手机回复或 + /// 三秒启动等待结束后再落盘,避免与即将到达的新课表重复写入。 + private var renderCacheNeedsPersistence = false + + /// 派生索引编码任务及其代次。 + /// + /// 同步的当天、14 天和整学期阶段可能连续触发索引更新。旧任务可以继续在 + /// 后台完成编码,但只有最新代次允许覆盖磁盘,避免迟到结果写回旧课表。 + private var renderCachePersistenceTask: Task? + private var renderCachePersistenceGeneration = 0 + + /// 初始化时立即恢复缓存,让界面在连接手机之前就可以显示。 + init( + defaults: UserDefaults = .standard, + sharedDefaults: UserDefaults? = WatchWidgetShared.defaults, + reloadWidgets: @escaping () -> Void = WatchWidgetShared.reloadWidgetTimelines + ) { + self.defaults = defaults + self.sharedDefaults = sharedDefaults + self.reloadWidgets = reloadWidgets + // Swift 要求所有无默认值的存储属性在调用实例方法前完成初始化。 + // 语言只需读取一次,后续缓存恢复和预热便可安全使用完整的 Store。 + self.preferredLanguageIdentifier = + WatchWidgetShared.preferredLanguageIdentifier + prepareLaunchState() + } + + /// 启动阶段一次完成离线数据、派生索引和当前月份窗口的准备。 + /// + /// 这里不创建 SwiftUI 页面,也不启动网络。根视图出现后只读取已经安装的 + /// 内存索引;WatchConnectivity 随后增量替换新数据。缓存编码不在此执行, + /// 缺失的派生缓存会交给后台持久化任务,避免阻塞第一帧。 + private func prepareLaunchState() { + installedScheduleVersion = defaults.string( + forKey: WatchPersistentCacheKey.installedSemesterVersion + ) + loadCachedSchedule() + discardOrphanedScheduleVersionIfNeeded() + // 有缓存时,索引安装流程已经完成预热;空课表仍在根视图出现前准备 + // 日期网格。条件判断避免启动时对同一个三页窗口重复组装。 + if snapshot == nil { + prewarmMonthCalendar(around: Date()) + } + } + + /// SwiftUI 根视图使用的语言环境;修改后整棵视图树会立即重新本地化。 + var preferredLocale: Locale { + WatchWidgetShared.locale(for: preferredLanguageIdentifier) + } + + /// 安装手机同步过来的语言,并写入 App Group 供 Widget 使用。 + @discardableResult + func setPreferredLanguage(_ value: String) -> Bool { + guard let normalized = + WatchWidgetShared.normalizedPreferredLanguage(value) + else { + return false + } + + let changed = preferredLanguageIdentifier != normalized + _ = WatchWidgetShared.updatePreferredLanguage( + normalized, in: sharedDefaults ?? defaults, reloadWidgets: reloadWidgets) + preferredLanguageIdentifier = normalized + if changed { + // 错误文本是在产生时本地化的;语言切换后清除旧文本,空状态会用 + // 新 Locale 重新生成默认提示,避免页面同时出现两种语言。 + syncError = nil + } + return changed + } + + /// 当前展示快照是否超过手机给出的有效期。 + var isStale: Bool { + guard let snapshot else { return true } + return isExpired(snapshot, comparedWith: Date()) + } + + /// 所有日程按开始时间稳定排序。 + var allCourses: [WatchCourse] { + sortedVisibleCourses + } + + /// 新手教学优先选取日程最多的一天;并列时选择最接近今天的一天。 + /// + /// 课程列表分组在安装课表时已经生成。直接复用该索引可避免每次打开 + /// 教学都再次对整学期课程做分组和排序,真机首帧会稳定很多。 + var recommendedOnboardingDate: Date? { + preparedOnboardingDate + } + + /// 教学所需的课程列表索引和月历三页窗口是否已经完整就绪。 + func hasPreparedOnboardingRenderData(around date: Date) -> Bool { + let sourceCount = snapshot?.courses.count ?? 0 + return indexedCourseCount == sourceCount + && monthCalendarCache.isPrepared(around: date) + } + + /// 在用户阅读概览教学时合作式预热后续页面的数据。 + /// + /// 正常启动会直接命中持久化派生缓存;只有缓存缺失或结构不完整时才 + /// 重建一次。阶段间主动 `yield`,让引导动画先提交当前帧,避免把所有 + /// 准备工作挤在同一次主线程更新中。SwiftUI 视图仍只在主线程创建, + /// 但课程分组、列表定位和月历标记不会再推迟到首次进入页面时计算。 + func prepareOnboardingRenderData(around date: Date) async { + await Task.yield() + guard !Task.isCancelled else { return } + + let sourceCount = snapshot?.courses.count ?? 0 + if indexedCourseCount != sourceCount { + rebuildVisibleScheduleIndex(persisting: false) + } + + await Task.yield() + guard !Task.isCancelled else { return } + prewarmMonthCalendar(around: date) + // 不在教学动画期间编码和写入 UserDefaults。缺失索引已经标记为 + // `renderCacheNeedsPersistence`,启动同步回复或三秒等待结束后会走 + // 既有持久化入口;这里仅准备当前会话马上要绘制的数据。 + } + + /// 本地持久化缓存中是否至少包含一条本学期日程。 + /// + /// “已成功解码一个快照”不等于“已有课表”:手机可能同步过覆盖范围与 + /// 周次等元数据、但 `courses` 为空的快照。启动超时提示必须检查实际 + /// 课程、考试或实验记录,避免空快照被误报为“已加载缓存课表”。历史 + /// 日程仍属于本学期课表,因此不按结束时间或有效期排除。整学期缓存一旦 + /// 存在便是权威结果:若它为空,不得再被旧的当天/14 天缓存误判为有课。 + var hasCachedScheduleContent: Bool { + !(snapshot?.courses.isEmpty ?? true) + } + + var presentationTimelineDates: [Date] { + WatchSchedulePresentation.timelineDates(resolved: resolvedPresentation, now: Date()) + } + + func presentation(at date: Date) -> WatchSchedulePresentation { + WatchSchedulePresentation(resolved: resolvedPresentation, at: date, + signedOut: defaults.bool(forKey: WatchWidgetShared.signedOutKey)) + } + + func clearSchedule(signedOut: Bool) { + renderCachePersistenceGeneration &+= 1 + renderCachePersistenceTask?.cancel() + renderCachePersistenceTask = nil + clearSemesterBuffer(keepingCapacity: false) + cachedSnapshots.removeAll() + resolvedPresentation = nil + snapshot = nil + loadedScope = nil + installedScheduleVersion = nil + WatchWidgetShared.clearSchedule(in: defaults) + defaults.set(signedOut, forKey: WatchWidgetShared.signedOutKey) + if let shared = sharedDefaults { + WatchWidgetShared.clearSchedule(in: shared) + shared.set(signedOut, forKey: WatchWidgetShared.signedOutKey) + } + rebuildVisibleScheduleIndex(persisting: false) + renderCacheNeedsPersistence = false + receiveLaunchSyncReply() + finishRefresh() + reloadWidgets() + } + + /// 系统日历或时区变化时,原时间戳不变,但自然日分组需要重新建立。 + /// 前台恢复和系统通知共用此入口;普通激活只做一次常量时间比较。 + func refreshCalendarEnvironmentIfNeeded() { + guard indexedCalendar != Calendar.current else { return } + indexedCalendar = .current + rebuildVisibleScheduleIndex(persisting: true) + } + + /// 计算周次时使用的学期起点。 + /// + /// 优先采用当前页面数据;若当前仅展示当天数据,则回退到完整学期缓存。 + var semesterStart: Date? { + if let currentStart = snapshot?.semesterStart { + return currentStart + } + if let semester = cachedSnapshots[.semester] { + return semester.semesterStart ?? semester.rangeStart + } + guard loadedScope == .semester else { return nil } + return snapshot?.rangeStart + } + + /// 手机端同步过来的“当前周次”参考点。 + /// + /// 周视图用生成日期和零基周次计算其他周,避免手表自行猜测开学日期。 + var synchronizedWeekReference: ( + date: Date, + zeroBasedIndex: Int + )? { + if let snapshot, + let index = snapshot.currentWeekIndex + { + return (snapshot.generatedAt, index) + } + if let semester = cachedSnapshots[.semester], + let index = semester.currentWeekIndex + { + return (semester.generatedAt, index) + } + return nil + } + + /// 与手机课表 `0 ..< semesterLength` 完全一致的第一周开始日期。 + /// + /// 渐进同步的当天/14 天阶段范围较短,不能拿来限制周视图;这里只读取 + /// 已完整落盘的整学期快照,避免同步过程中错误缩小可浏览范围。 + var semesterRangeStart: Date? { + semesterNavigationSnapshot?.rangeStart + } + + /// 整学期范围的右开边界,即手机最后一周结束后的日期。 + var semesterRangeEnd: Date? { + semesterNavigationSnapshot?.rangeEnd + } + + /// 周视图日期限制所使用的完整学期快照。 + private var semesterNavigationSnapshot: WatchScheduleSnapshot? { + if let semester = cachedSnapshots[.semester] { + return semester + } + guard loadedScope == .semester else { return nil } + return snapshot + } + + /// 返回“仍未结束的第一条日程”。 + /// + /// 若当前正在上课,该课程也会被返回;这是“下一节课”页面同时承担 + /// “当前课程”展示的既有行为。 + var nextCourse: WatchCourse? { + firstUnfinishedCourse(at: Date()) + } + + /// 返回指定自然日内开始的全部日程。 + func courses(on date: Date) -> [WatchCourse] { + coursesByDay[Calendar.current.startOfDay(for: date)] ?? [] + } + + /// 返回从指定日期开始若干自然日内的课程,供周视图预加载相邻页面。 + /// + /// 只访问至多 `dayCount` 个字典槽位,不随整学期课程数量增长;表冠每次 + /// 改变横向偏移时,前、中、后三个周页面都可以稳定地复用这份索引。 + func courses(startingAt date: Date, dayCount: Int) -> [WatchCourse] { + guard dayCount > 0 else { return [] } + let calendar = Calendar.current + let start = calendar.startOfDay(for: date) + return (0.. MonthCalendarWindow { + monthCalendarCache.window( + centeredOn: date, + periodCourseIDsByDay: periodCourseIDsByDay, + coursesByID: coursesByID + ) + } + + /// 开始三阶段刷新,并保留当前可用页面内容。 + func beginRefresh() { + clearSemesterBuffer(keepingCapacity: true) + restoreCacheIfNeeded() + updateRefreshState( + isRefreshing: true, + loadingScope: .today, + error: nil + ) + } + + /// 每次 App 打开或重新回到前台时开始等待手机的实时回复。 + /// + /// 该状态与普通课表刷新分离:同步失败或超时都不能清空现有快照。这样 + /// 有缓存时页面会继续可用,只显示一条紧凑提醒;完全没有缓存时才由 + /// 根视图切换到“打开手机”的整页引导。 + func beginLaunchSyncAttempt() { + isAwaitingLaunchSyncReply = true + launchSyncTimedOut = false + } + + /// 收到本轮手机实时通信后清除离线提示。 + /// + /// 这里只表示手机已经响应;当天、14 天、学期三个阶段仍由各自的 + /// 安装流程决定何时替换页面以及何时结束刷新动画。 + func receiveLaunchSyncReply() { + isAwaitingLaunchSyncReply = false + launchSyncTimedOut = false + } + + /// 启动请求在限定时间内没有收到手机实时回复。 + /// + /// 不调用 `failRefresh`,也不修改 `snapshot`,以保证已有缓存原样保留。 + func markLaunchSyncTimedOut() { + isAwaitingLaunchSyncReply = false + launchSyncTimedOut = true + persistRenderCacheIfNeeded() + } + + /// 进入指定同步阶段。 + func setLoadingScope(_ scope: WatchScheduleScope) { + updateRefreshState( + isRefreshing: true, + loadingScope: scope, + error: nil + ) + } + + /// 结束刷新;仅整个渐进流程完成时递增提示计数。 + func finishRefresh(showCompletion: Bool = false) { + updateRefreshState( + isRefreshing: false, + loadingScope: nil, + error: nil + ) + if showCompletion { + completedRefreshCount += 1 + } + } + + /// 手机确认版本未变化时恢复已安装整学期缓存并结束刷新。 + /// + /// 通常页面本来就是该缓存;额外恢复用于处理一个极端竞态:同步过程中 + /// 手机课表先变化、随后又恢复为手表已安装版本,避免保留先前局部阶段 + /// 已经合入页面的临时内容。 + func finishRefreshWithoutScheduleChanges() { + clearSemesterBuffer(keepingCapacity: false) + if let semester = cachedSnapshots[.semester] { + for scope in [WatchScheduleScope.today, .fourteenDays] { + cachedSnapshots.removeValue(forKey: scope) + let key = WatchWidgetShared.cacheKey(for: scope) + defaults.removeObject(forKey: key) + sharedDefaults?.removeObject(forKey: key) + } + resolvedPresentation = WatchScheduleResolver.resolve(cachedSnapshots) + snapshot = semester + loadedScope = .semester + reloadWidgets() + syncError = nil + prepareVisibleScheduleIndex( + preferringPersistentCache: true, + persistIfRebuilt: true + ) + } + persistRenderCacheIfNeeded() + finishRefresh(showCompletion: true) + } + + /// 刷新失败时保留最后一份完整缓存,并向界面报告原因。 + func failRefresh(_ message: String) { + clearSemesterBuffer(keepingCapacity: false) + restoreCacheIfNeeded() + persistRenderCacheIfNeeded() + updateRefreshState( + isRefreshing: false, + loadingScope: nil, + error: message + ) + } + + /// 接收当天或近 14 天的一次完整 JSON 快照。 + /// + /// 只有解码和 schema 校验都成功后,才会替换页面与对应缓存。 + @discardableResult + func replaceSchedule( + json: String, + scope: WatchScheduleScope + ) -> Bool { + guard hasPayload(json) else { + setMissingScheduleErrorIfNeeded() + return false + } + + do { + let decoded = try decode(json) + return installProgressiveSnapshot( + decoded, + json: json, + scope: scope + ) + } catch { + syncError = watchLocalizedString("课表数据无法读取") + logFailure(.scheduleDecode, error: error) + return false + } + } + + /// 准备接收分块的整学期课表。 + func beginSemesterTransfer() { + clearSemesterBuffer(keepingCapacity: true) + setLoadingScope(.semester) + } + + /// 合并一个学期分块,并在最后一块到达时一次性生成完整快照。 + /// + /// 非最后一块只进入内存缓冲区,不会覆盖原有缓存;因此中途断线时, + /// 手表仍然可以继续展示同步前的完整数据。 + @discardableResult + func appendSemesterChunk( + json: String, + isFinal: Bool, + scheduleVersion: String? + ) -> Bool { + guard hasPayload(json) else { + failRefresh(watchLocalizedString("手机端没有可用的学期课表")) + return false + } + + do { + let chunk = try decode(json) + try semesterTransfer.append(chunk) + + guard isFinal else { return true } + try completeSemesterTransfer( + scheduleVersion: scheduleVersion + ) + return true + } catch { + failRefresh(watchLocalizedString("全学期课表无法合并")) + logFailure(.semesterMerge, error: error) + return false + } + } + + /// 统一更新刷新状态,防止多个入口漏改某个属性。 + private func updateRefreshState( + isRefreshing: Bool, + loadingScope: WatchScheduleScope?, + error: String? + ) { + self.isRefreshing = isRefreshing + self.loadingScope = loadingScope + syncError = error + } + + /// 判断字符串是否包含可尝试解码的数据。 + private func hasPayload(_ json: String) -> Bool { + WatchScheduleText.nonempty(json) != nil + } + + /// 只有当前完全没有页面内容时才显示“手机暂无课表”。 + private func setMissingScheduleErrorIfNeeded() { + guard snapshot == nil else { return } + syncError = watchLocalizedString( + "手机端暂无课表,请先在 iPhone 打开并刷新课表" + ) + } + + /// 解码课表并验证数据结构版本。 + private func decode(_ json: String) throws -> WatchScheduleSnapshot { + try WatchScheduleCoding.decode(json) + } + + /// 将完整快照编码回共享缓存格式。 + private func encode(_ snapshot: WatchScheduleSnapshot) throws -> String { + try WatchCacheCoding.encodeJSON(snapshot) + } + + /// 与 Widget 采用同一合并规则,空的局部快照也会删除对应范围的旧课程。 + private func installProgressiveSnapshot( + _ completedSnapshot: WatchScheduleSnapshot, + json: String, + scope: WatchScheduleScope + ) -> Bool { + if let newest = cachedSnapshots.values.max(by: { $0.freshnessStamp < $1.freshnessStamp }), + newest.semesterStartEpochMs != completedSnapshot.semesterStartEpochMs { + guard completedSnapshot.freshnessStamp >= newest.freshnessStamp else { return false } + clearSchedule(signedOut: false) + } + if let existing = cachedSnapshots[scope], existing.freshnessStamp > completedSnapshot.freshnessStamp { return false } + persistCompletedStage(snapshot: completedSnapshot, json: json, scope: scope) + defaults.set(false, forKey: WatchWidgetShared.signedOutKey) + sharedDefaults?.set(false, forKey: WatchWidgetShared.signedOutKey) + if let resolved = WatchScheduleResolver.resolve(cachedSnapshots) { + resolvedPresentation = resolved + snapshot = resolved.snapshot + loadedScope = resolved.scope + } + syncError = nil + rebuildVisibleScheduleIndex(persisting: true) + return true + } + + /// 原子完成学期传输:构造、编码、持久化成功后才替换当前页面。 + private func completeSemesterTransfer( + scheduleVersion: String? + ) throws { + let complete = try semesterTransfer.completedSnapshot() + let json = try encode(complete) + + guard installProgressiveSnapshot( + complete, + json: json, + scope: .semester + ) else { throw WatchScheduleDataError.outdatedSnapshot } + installCompletedScheduleVersion(scheduleVersion) + clearSemesterBuffer(keepingCapacity: false) + finishRefresh(showCompletion: true) + } + + /// 只有整学期全部分页安装成功后才确认版本。 + /// + /// 当天或 14 天阶段不能写入版本,否则手表可能只有局部缓存,却在下一次 + /// 请求中误报“已完整安装”。缺少版本号代表旧协议,主动清除旧版本以便 + /// 下次仍执行正常三阶段同步。 + private func installCompletedScheduleVersion(_ value: String?) { + guard let value, !value.isEmpty else { + installedScheduleVersion = nil + defaults.removeObject( + forKey: WatchPersistentCacheKey.installedSemesterVersion + ) + return + } + + installedScheduleVersion = value + defaults.set( + value, + forKey: WatchPersistentCacheKey.installedSemesterVersion + ) + } + + /// 完整学期缓存损坏或被清除时同步清除孤立版本号。 + /// + /// 否则手表可能只有一个版本号却没有对应课表,向手机误报“无需更新”后 + /// 永远停留在空页面。 + private func discardOrphanedScheduleVersionIfNeeded() { + guard installedScheduleVersion != nil, + cachedSnapshots[.semester] == nil + else { + return + } + installedScheduleVersion = nil + defaults.removeObject( + forKey: WatchPersistentCacheKey.installedSemesterVersion + ) + } + + /// 清空学期分块缓冲区。 + private func clearSemesterBuffer(keepingCapacity: Bool) { + semesterTransfer.reset(keepingCapacity: keepingCapacity) + } + + /// 从标准缓存和 App Group 中恢复每个阶段。 + /// + /// 两个来源会分别尝试解码;任一来源损坏时仍会继续检查另一份缓存。 + private func loadCachedSchedule() { + for scope in WatchWidgetShared.scheduleCacheScopesByPriority { + let key = WatchWidgetShared.cacheKey(for: scope) + guard let cached = loadNewestValidCache( + key: key + ) else { + continue + } + + cachedSnapshots[scope] = cached.snapshot + migrateToSharedCacheIfNeeded( + json: cached.json, + key: key + ) + } + restoreCacheIfNeeded() + } + + /// 两个缓存内容相同时只解码一次,内容不同时选择修订号最新的有效结果。 + private func loadNewestValidCache( + key: String + ) -> (snapshot: WatchScheduleSnapshot, json: String)? { + let candidates = [ + defaults.string(forKey: key), + sharedDefaults?.string(forKey: key), + ].compactMap { $0 } + var seen = Set() + return candidates.filter { seen.insert($0).inserted } + .compactMap { json -> (snapshot: WatchScheduleSnapshot, json: String)? in + guard let snapshot = try? decode(json) else { return nil } + return (snapshot, json) + }.max { $0.snapshot.freshnessStamp < $1.snapshot.freshnessStamp } + } + + /// 旧版本只有标准缓存时,将有效数据迁移到 Widget 可读的 App Group。 + private func migrateToSharedCacheIfNeeded( + json: String, + key: String + ) { + guard sharedDefaults?.string(forKey: key) != json else { return } + sharedDefaults?.set(json, forKey: key) + } + + /// 写入一个已经完整完成的同步阶段。 + private func persistCompletedStage( + snapshot: WatchScheduleSnapshot, + json: String, + scope: WatchScheduleScope + ) { + cachedSnapshots[scope] = snapshot + defaults.set(json, forKey: WatchWidgetShared.cacheKey(for: scope)) + sharedDefaults?.set(json, forKey: WatchWidgetShared.cacheKey(for: scope)) + reloadWidgets() + } + + /// 当前没有页面数据时,恢复优先级最高的缓存。 + private func restoreCacheIfNeeded() { + guard snapshot == nil, + let cached = WatchScheduleResolver.resolve(cachedSnapshots) + else { + return + } + resolvedPresentation = cached + snapshot = cached.snapshot + loadedScope = cached.scope + prepareVisibleScheduleIndex( + preferringPersistentCache: true, + persistIfRebuilt: false + ) + } + + /// 判断快照是否已经过期。 + private func isExpired( + _ snapshot: WatchScheduleSnapshot, + comparedWith date: Date + ) -> Bool { + snapshot.validThrough < date + } + + /// 对任意课程集合进行稳定排序。 + private func sortedCourses( + _ courses: [WatchCourse] + ) -> [WatchCourse] { + WatchScheduleResolver.sorted(courses) + } + + /// 优先恢复持久化派生缓存;缺失或校验失败时才遍历原始课表重建。 + private func prepareVisibleScheduleIndex( + preferringPersistentCache: Bool, + persistIfRebuilt: Bool + ) { + if preferringPersistentCache, restorePersistedRenderCache() { + return + } + + rebuildVisibleScheduleIndex(persisting: persistIfRebuilt) + if !persistIfRebuilt { + renderCacheNeedsPersistence = true + } + } + + /// 预生成全部视图共用的派生数据。 + /// + /// 只有手机实际发来某个新同步阶段,或本地派生缓存不可用时才执行。排序、 + /// 按日分组、课程列表定位及月视图五段课程 ID 在一次遍历内完成,进入任一 + /// 页面时都只做字典读取。 + private func rebuildVisibleScheduleIndex(persisting: Bool) { + let sorted = sortedCourses(snapshot?.courses ?? []) + let calendar = Calendar.current + let grouped = Dictionary(grouping: sorted) { + calendar.startOfDay(for: $0.startAt) + } + let groups = makeCourseDayGroups(from: grouped) + + installVisibleScheduleIndex( + sorted: sorted, + grouped: grouped, + groups: groups, + initialDate: preferredCourseListDate( + in: groups, + calendar: calendar + ) + ) + + if persisting { + persistRenderCache() + } else { + renderCacheNeedsPersistence = true + } + } + + /// 把已计算或已恢复的索引一次性安装到所有视图读取的内存状态。 + private func installVisibleScheduleIndex( + sorted: [WatchCourse], + grouped: [Date: [WatchCourse]], + groups: [WatchCourseDayGroup], + initialDate: Date?, + restoredCourseMap: [String: WatchCourse]? = nil, + restoredPeriodCourseIDs: [Date: [String?]]? = nil + ) { + sortedVisibleCourses = sorted + indexedCalendar = .current + coursesByDay = grouped + courseListGroups = groups + courseListInitialDate = initialDate + indexedCourseCount = sorted.count + preparedOnboardingDate = preferredOnboardingDate( + in: groups, + calendar: .current + ) + + if let restoredCourseMap { + coursesByID = restoredCourseMap + } else { + var byID: [String: WatchCourse] = [:] + byID.reserveCapacity(sorted.count) + for course in sorted { + byID[course.id] = course + } + coursesByID = byID + } + periodCourseIDsByDay = restoredPeriodCourseIDs + ?? makePeriodCourseIDsByDay(from: grouped) + // 日期格模型可以继续复用,但颜色标记必须与新课表索引一起失效。 + monthCalendarCache.invalidateScheduleMarkers() + prewarmInitialMonthWindows() + renderCacheRevision &+= 1 + } + + /// 在索引安装阶段准备首次使用月视图和教学可能访问的月份窗口。 + /// + /// 当前日期用于正常的月视图入口;教学日期用于首次引导。两者属于同一 + /// 个月时只执行一次。页面首次出现后只读取缓存,不再临时组装日期格和 + /// 五段课程标记。 + private func prewarmInitialMonthWindows() { + let today = Date() + prewarmMonthCalendar(around: today) + guard let preparedOnboardingDate, + monthCalendarStart(for: preparedOnboardingDate) + != monthCalendarStart(for: today) + else { return } + prewarmMonthCalendar(around: preparedOnboardingDate) + } + + /// 按自然日生成课程列表分组,统一新建和恢复两条路径的顺序。 + private func makeCourseDayGroups( + from grouped: [Date: [WatchCourse]] + ) -> [WatchCourseDayGroup] { + grouped.keys.sorted().map { date in + WatchCourseDayGroup( + date: date, + courses: grouped[date] ?? [] + ) + } + } + + /// 选择日程最多的教学日期;数量相同时优先选择最接近今天的一天。 + private func preferredOnboardingDate( + in groups: [WatchCourseDayGroup], + calendar: Calendar + ) -> Date? { + let today = calendar.startOfDay(for: Date()) + return groups.min { lhs, rhs in + if lhs.courses.count != rhs.courses.count { + return lhs.courses.count > rhs.courses.count + } + let lhsDistance = abs(lhs.date.timeIntervalSince(today)) + let rhsDistance = abs(rhs.date.timeIntervalSince(today)) + if lhsDistance != rhsDistance { + return lhsDistance < rhsDistance + } + return lhs.date < rhs.date + }?.date + } + + /// 为每个有日程的自然日选出五个两节区间中的第一项日程。 + private func makePeriodCourseIDsByDay( + from grouped: [Date: [WatchCourse]] + ) -> [Date: [String?]] { + var result: [Date: [String?]] = [:] + result.reserveCapacity(grouped.count) + for (day, courses) in grouped { + result[day] = makePeriodCourseIDs(from: courses) + } + return result + } + + /// 输入沿用日程顺序;同一课时有多项安排时固定选择第一项。 + /// 缓存恢复也使用此规则,防止有效课程 ID 被放入错误的课时。 + private func makePeriodCourseIDs(from courses: [WatchCourse]) -> [String?] { + WatchScheduleRenderCacheLayout.periodRanges.map { periodRange in + courses.first { + $0.startPeriod <= periodRange.upperBound + && $0.endPeriod >= periodRange.lowerBound + }?.id + } + } + + /// 将内存索引交给后台任务编码,并只提交最新一代结果。 + /// + /// 同步阶段会先立即安装新的内存索引,页面因此可以马上刷新;JSON 编码 + /// 不占用主线程。当天、14 天和整学期连续到达时,代次校验会丢弃旧任务 + /// 的迟到结果,避免重复写盘或让旧索引覆盖新索引。 + private func persistRenderCache() { + guard let cache = makePersistedRenderCache() else { + renderCachePersistenceGeneration &+= 1 + renderCachePersistenceTask?.cancel() + renderCachePersistenceTask = nil + defaults.removeObject( + forKey: WatchPersistentCacheKey.scheduleRenderIndex + ) + renderCacheNeedsPersistence = false + return + } + + renderCacheNeedsPersistence = true + renderCachePersistenceGeneration &+= 1 + let generation = renderCachePersistenceGeneration + renderCachePersistenceTask?.cancel() + renderCachePersistenceTask = Task { @MainActor [weak self] in + do { + let data = try await WatchCacheCoding.encodeInBackground(cache) + guard let self, + !Task.isCancelled, + generation == self.renderCachePersistenceGeneration + else { return } + WatchCacheCoding.persist( + data, + key: WatchPersistentCacheKey.scheduleRenderIndex, + defaults: self.defaults + ) + self.renderCacheNeedsPersistence = false + self.renderCachePersistenceTask = nil + } catch is CancellationError { + return + } catch { + guard let self, + generation == self.renderCachePersistenceGeneration + else { return } + self.renderCacheNeedsPersistence = true + self.renderCachePersistenceTask = nil + self.logFailure(.renderCacheEncoding, error: error) + } + } + } + + /// 捕获当前内存索引,生成可安全交给后台编码的值类型快照。 + private func makePersistedRenderCache() -> PersistedScheduleRenderCache? { + guard let snapshot else { return nil } + let calendar = Calendar.current + let days = coursesByDay.keys.sorted().map { day in + PersistedScheduleRenderDay( + dayStartEpochMs: epochMilliseconds(day), + courseIDs: (coursesByDay[day] ?? []).map(\.id), + periodCourseIDs: periodCourseIDsByDay[day] + ?? Array( + repeating: nil, + count: WatchScheduleRenderCacheLayout + .periodRanges.count + ) + ) + } + let today = calendar.startOfDay(for: Date()) + return PersistedScheduleRenderCache( + schemaVersion: WatchScheduleRenderCacheLayout.schemaVersion, + source: renderCacheSource(for: snapshot), + sortedCourseIDs: sortedVisibleCourses.map(\.id), + days: days, + courseListReferenceDayEpochMs: epochMilliseconds(today), + courseListInitialDayEpochMs: courseListInitialDate.map( + epochMilliseconds + ) + ) + } + + /// 启动等待结束且手机未下发新课表时,补写缺失的派生缓存。 + private func persistRenderCacheIfNeeded() { + guard renderCacheNeedsPersistence else { return } + persistRenderCache() + } + + /// 从磁盘恢复派生索引,并用原始快照做结构完整性校验。 + /// + /// 这里只验证缓存是否属于当前快照,不在 Watch 端计算或比较课表语义 + /// 版本;“是否有新课表”仍完全以 iPhone 的版本回复为准。 + private func restorePersistedRenderCache() -> Bool { + guard let snapshot, + let cache = try? WatchCacheCoding.load( + PersistedScheduleRenderCache.self, + key: WatchPersistentCacheKey.scheduleRenderIndex, + defaults: defaults + ), + cache.schemaVersion + == WatchScheduleRenderCacheLayout.schemaVersion, + cache.source == renderCacheSource(for: snapshot) + else { + return false + } + + guard let restored = restoreScheduleRenderIndex( + cache, + snapshot: snapshot + ) + else { + return false + } + + let sorted = cache.sortedCourseIDs.compactMap { + restored.coursesByID[$0] + } + let groups = makeCourseDayGroups(from: restored.coursesByDay) + let position = restoreCourseListPosition( + from: cache, + groups: groups, + calendar: .current + ) + renderCacheNeedsPersistence = position.needsPersistence + + // 新建和恢复两条路径最终都经过同一安装入口,确保以后新增派生字段时 + // 不会只更新其中一条路径。恢复值已经完成完整性校验,因此直接复用, + // 复用已通过校验的日分组和五段索引。 + installVisibleScheduleIndex( + sorted: sorted, + grouped: restored.coursesByDay, + groups: groups, + initialDate: position.date, + restoredCourseMap: restored.coursesByID, + restoredPeriodCourseIDs: restored.periodCourseIDsByDay + ) + return true + } + + /// 校验持久化课程顺序、自然日归属和五段课程引用,并恢复字典索引。 + private func restoreScheduleRenderIndex( + _ cache: PersistedScheduleRenderCache, + snapshot: WatchScheduleSnapshot + ) -> RestoredScheduleRenderIndex? { + var courseMap: [String: WatchCourse] = [:] + courseMap.reserveCapacity(snapshot.courses.count) + for course in snapshot.courses { + courseMap[course.id] = course + } + guard courseMap.count == snapshot.courses.count, + cache.sortedCourseIDs.count == snapshot.courses.count, + Set(cache.sortedCourseIDs) == Set(courseMap.keys), + WatchScheduleResolver.isSorted(cache.sortedCourseIDs.compactMap { courseMap[$0] }) + else { + return nil + } + + let calendar = Calendar.current + var grouped: [Date: [WatchCourse]] = [:] + var periodIDsByDay: [Date: [String?]] = [:] + var groupedCourseIDs: [String] = [] + groupedCourseIDs.reserveCapacity(snapshot.courses.count) + + for day in cache.days { + guard day.periodCourseIDs.count + == WatchScheduleRenderCacheLayout.periodRanges.count + else { + return nil + } + let date = date(fromEpochMilliseconds: day.dayStartEpochMs) + let courses = day.courseIDs.compactMap { courseMap[$0] } + guard grouped[date] == nil, + !courses.isEmpty, + courses.count == day.courseIDs.count, + WatchScheduleResolver.isSorted(courses), + courses.allSatisfy({ + calendar.startOfDay(for: $0.startAt) == date + }), + day.periodCourseIDs == makePeriodCourseIDs(from: courses) + else { + return nil + } + grouped[date] = courses + periodIDsByDay[date] = day.periodCourseIDs + groupedCourseIDs.append(contentsOf: day.courseIDs) + } + guard groupedCourseIDs.count == snapshot.courses.count, + Set(groupedCourseIDs) == Set(courseMap.keys) + else { + return nil + } + return RestoredScheduleRenderIndex( + coursesByID: courseMap, + coursesByDay: grouped, + periodCourseIDsByDay: periodIDsByDay + ) + } + + /// 恢复课程列表入口;缓存跨自然日后只重算入口,不重建其他索引。 + private func restoreCourseListPosition( + from cache: PersistedScheduleRenderCache, + groups: [WatchCourseDayGroup], + calendar: Calendar + ) -> RestoredCourseListPosition { + let today = calendar.startOfDay(for: Date()) + let referenceDay = date( + fromEpochMilliseconds: cache.courseListReferenceDayEpochMs + ) + let cachedDate = cache.courseListInitialDayEpochMs.map { + date(fromEpochMilliseconds: $0) + } + let cachedDateIsValid = cachedDate.map { initial in + groups.contains(where: { $0.date == initial }) + } ?? groups.isEmpty + + if referenceDay == today, cachedDateIsValid { + return RestoredCourseListPosition( + date: cachedDate, + needsPersistence: false + ) + } + return RestoredCourseListPosition( + date: preferredCourseListDate(in: groups, calendar: calendar), + needsPersistence: true + ) + } + + /// 创建轻量来源标识,只用于防止原始快照与派生缓存错配。 + private func renderCacheSource( + for snapshot: WatchScheduleSnapshot + ) -> PersistedScheduleRenderSource { + PersistedScheduleRenderSource( + snapshotSchemaVersion: snapshot.schemaVersion, + generatedAtEpochMs: snapshot.generatedAtEpochMs, + sourceRevision: snapshot.sourceRevision, + calendarIdentifier: String(describing: Calendar.current.identifier), + timeZoneIdentifier: Calendar.current.timeZone.identifier, + rangeStartEpochMs: snapshot.rangeStartEpochMs, + rangeEndEpochMs: snapshot.rangeEndEpochMs, + courseCount: snapshot.courses.count + ) + } + + /// 将 Date 转换为缓存统一使用的 Unix 毫秒。 + private func epochMilliseconds(_ date: Date) -> Int64 { + WatchScheduleDate.epochMilliseconds(for: date) + } + + /// 将缓存毫秒时间戳转换回 Date。 + private func date(fromEpochMilliseconds value: Int64) -> Date { + WatchScheduleDate.date(fromEpochMilliseconds: value) + } + + /// 首次打开列表优先定位今天;今天无课则选择距离最近的有课日期。 + /// + /// 前后距离相同时优先未来日期,便于用户直接查看接下来要上的课。 + private func preferredCourseListDate( + in groups: [WatchCourseDayGroup], + calendar: Calendar + ) -> Date? { + let today = calendar.startOfDay(for: Date()) + if groups.contains(where: { + calendar.isDate($0.date, inSameDayAs: today) + }) { + return today + } + + return groups.min { lhs, rhs in + let lhsDistance = abs(lhs.date.timeIntervalSince(today)) + let rhsDistance = abs(rhs.date.timeIntervalSince(today)) + if lhsDistance == rhsDistance { + return lhs.date > rhs.date + } + return lhsDistance < rhsDistance + }?.date + } + + /// 从已排序日程中查找指定时刻后仍未结束的第一条。 + private func firstUnfinishedCourse(at date: Date) -> WatchCourse? { + allCourses.first { $0.endAt > date } + } + + /// 统一输出 Store 的可恢复错误;界面提示和回退策略由调用点负责。 + private func logFailure( + _ context: ScheduleStoreFailureContext, + error: Error + ) { + NSLog("[WatchScheduleStore] \(context.message): \(error)") + } +} + +/// Store 日志分类只描述失败发生的阶段,不参与用户可见错误文案。 +private enum ScheduleStoreFailureContext { + case scheduleDecode + case semesterMerge + case invalidCache(WatchScheduleScope) + case renderCacheEncoding + + var message: String { + switch self { + case .scheduleDecode: + "Schedule decode failed" + case .semesterMerge: + "Semester merge failed" + case .invalidCache(let scope): + "Ignoring invalid \(scope.rawValue) cache" + case .renderCacheEncoding: + "Render cache encode failed" + } + } +} diff --git a/watchOS/TraintimeWatch.entitlements b/watchOS/TraintimeWatch.entitlements new file mode 100644 index 00000000..1783c1ef --- /dev/null +++ b/watchOS/TraintimeWatch.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.xyz.superbart.xdyou + + + diff --git a/watchOS/TraintimeWatchApp.swift b/watchOS/TraintimeWatchApp.swift new file mode 100644 index 00000000..f7768f40 --- /dev/null +++ b/watchOS/TraintimeWatchApp.swift @@ -0,0 +1,39 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import SwiftUI + +/// Apple Watch 独立应用入口。 +/// +/// `WatchScheduleStore` 由场景根节点持有,所有子视图共享同一份缓存与刷新状态; +/// WatchConnectivity 单例只负责传输,不直接拥有界面状态。 +@main +struct TraintimeWatchApp: App { + @Environment(\.scenePhase) private var scenePhase + @StateObject private var store = WatchScheduleStore() + + var body: some Scene { + WindowGroup { + RootScheduleView() + .environmentObject(store) + // 手机语言是产品内设置,不一定等于手表系统语言。将同步值注入 + // 根环境后,所有 SwiftUI Text 与日期格式会在原地立即刷新。 + .environment(\.locale, store.preferredLocale) + .task { + // 首次进入时恢复的本地缓存已经可以显示;随后激活手机通信, + // 新数据会按“当天、14 天、整学期”逐阶段替换。 + WatchConnectivityManager.shared.activate(store: store) + } + .onChange(of: scenePhase) { _, phase in + guard phase == .active else { return } + store.refreshCalendarEnvironmentIfNeeded() + // 每次回到前台都建立新的实时回复等待窗口。旧缓存保持可见; + // 超时后根视图会按“有缓存提示、无缓存整页”分别处理。 + WatchConnectivityManager.shared.beginLaunchRefresh() + } + .onReceive(NotificationCenter.default.publisher(for: .NSSystemTimeZoneDidChange)) { _ in + store.refreshCalendarEnvironmentIfNeeded() + } + } + } +} diff --git a/watchOS/Views/CalendarPagingSupport.swift b/watchOS/Views/CalendarPagingSupport.swift new file mode 100644 index 00000000..0fceaf78 --- /dev/null +++ b/watchOS/Views/CalendarPagingSupport.swift @@ -0,0 +1,678 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import SwiftUI + +/// 表冠横向翻页在新会话或反转方向后的渐进速度状态。 +/// +/// 前两张完整页面使用 50% 行程,第三张起恢复标准行程;只统计表冠真正 +/// 跨过的页面,触摸拖动和吸附动画不会改变计数。 +struct CalendarCrownPageRamp { + private(set) var committedPageCount = 0 + + var distanceScale: CGFloat { + committedPageCount < 2 ? 0.5 : 1 + } + + mutating func register(_ update: WatchCrownTurnUpdate) { + if update.startsNewSession || update.reversesDirection { + committedPageCount = 0 + } + } + + mutating func recordCommittedPage() { + committedPageCount = min(2, committedPageCount + 1) + } + + mutating func reset() { + committedPageCount = 0 + } +} + +/// 单个触摸层判定横向翻页或纵向滚动的轴向。 +enum CalendarPagingDragAxis: Equatable { + case horizontal + case vertical +} + +/// 三页分页器中的稳定页面描述。 +/// +/// `relativePage` 决定页面此刻位于左、中、右哪一格;`id` 使用真实日期。 +/// 日期推进后,SwiftUI 因而可以把已渲染的页面移动到新位置,而不是把三页 +/// 全部销毁重建。 +struct CalendarPagerPage: Identifiable { + let relativePage: Int + let id: Date +} + +/// 仿系统日历的三页横向容器。 +/// +/// 前一页、当前页、后一页始终并排预渲染,外部只需要提供一个连续像素偏移。 +/// 手指横拖和表冠旋转共用位移容器;父视图决定在吸附完成或表冠跨越整页时 +/// 提交日期,并同步轮换三页数据。 +struct CalendarHorizontalPager: View { + let pageOffset: CGFloat + let interactionResetToken: Int + let pageIdentity: (Int) -> Date + let page: (Int) -> Page + let onViewportWidthChange: (CGFloat) -> Void + let onViewportHeightChange: (CGFloat) -> Void + let onHorizontalDragBegan: () -> Void + let onHorizontalDragChanged: (CGFloat) -> Void + let onHorizontalDragEnded: (DragGesture.Value) -> Void + let onVerticalDragBegan: () -> Void + let onVerticalDragChanged: (CGFloat) -> Void + let onVerticalDragEnded: (DragGesture.Value) -> Void + let onDragAxisLocked: (CalendarPagingDragAxis) -> Void + let onDragCancelled: (CalendarPagingDragAxis) -> Void + + @GestureState private var dragIsRecognized = false + @State private var dragAxis: CalendarPagingDragAxis? + @State private var horizontalDragStarted = false + @State private var verticalDragStarted = false + @State private var dragStartTime: Date? + + /// 给初始细小抖动约一到两帧的观察窗口,再锁定整次触摸的方向。 + /// 一旦锁定,手指中途偏斜也不会让横纵手势互相抢占。 + private let directionDetectionWindow: TimeInterval = 0.035 + private let directionDetectionDistance: CGFloat = 5 + private let directionDominanceRatio: CGFloat = 1.1 + + init( + pageOffset: CGFloat, + interactionResetToken: Int = 0, + pageIdentity: @escaping (Int) -> Date, + @ViewBuilder page: @escaping (Int) -> Page, + onViewportWidthChange: @escaping (CGFloat) -> Void, + onViewportHeightChange: @escaping (CGFloat) -> Void, + onHorizontalDragBegan: @escaping () -> Void, + onHorizontalDragChanged: @escaping (CGFloat) -> Void, + onHorizontalDragEnded: @escaping (DragGesture.Value) -> Void, + onVerticalDragBegan: @escaping () -> Void, + onVerticalDragChanged: @escaping (CGFloat) -> Void, + onVerticalDragEnded: @escaping (DragGesture.Value) -> Void, + onDragAxisLocked: @escaping (CalendarPagingDragAxis) -> Void, + onDragCancelled: @escaping (CalendarPagingDragAxis) -> Void = { _ in } + ) { + self.pageOffset = pageOffset + self.interactionResetToken = interactionResetToken + self.pageIdentity = pageIdentity + self.page = page + self.onViewportWidthChange = onViewportWidthChange + self.onViewportHeightChange = onViewportHeightChange + self.onHorizontalDragBegan = onHorizontalDragBegan + self.onHorizontalDragChanged = onHorizontalDragChanged + self.onHorizontalDragEnded = onHorizontalDragEnded + self.onVerticalDragBegan = onVerticalDragBegan + self.onVerticalDragChanged = onVerticalDragChanged + self.onVerticalDragEnded = onVerticalDragEnded + self.onDragAxisLocked = onDragAxisLocked + self.onDragCancelled = onDragCancelled + } + + var body: some View { + GeometryReader { proxy in + let pages = (-1...1).map { + CalendarPagerPage( + relativePage: $0, + id: pageIdentity($0) + ) + } + HStack(spacing: 0) { + ForEach(pages) { descriptor in + page(descriptor.relativePage) + .frame( + width: proxy.size.width, + height: proxy.size.height + ) + // 内容页优先处理日视图纵向滚动、轻点收口和周课程点击。 + // 分页手势挂在页面上,但位移使用外层固定命名坐标,不会因页面 + // 自身移动而产生坐标反馈抽动。 + .simultaneousGesture(pagingGesture) + } + } + // 三张页面使用完全对称的三屏布局,避免把超宽内容 + // 压入单屏容器后造成前一页与后一页的非对称裁剪。 + .frame( + width: proxy.size.width * 3, + height: proxy.size.height, + alignment: .leading + ) + .offset(x: -proxy.size.width + pageOffset) + .onAppear { + onViewportWidthChange(proxy.size.width) + onViewportHeightChange(proxy.size.height) + } + .onChange(of: proxy.size.width) { _, width in + onViewportWidthChange(width) + } + .onChange(of: proxy.size.height) { _, height in + onViewportHeightChange(height) + } + .onChange(of: interactionResetToken) { _, _ in + resetTouchRecognition() + } + .onChange(of: dragIsRecognized) { _, isRecognized in + guard !isRecognized, let cancelledAxis = dragAxis else { return } + // 页面复用或系统抢占可能取消 DragGesture,不会经过 onEnded。 + // 只把当前位移收口,不把取消当成教学操作或额外翻页。 + resetTouchRecognition() + onDragCancelled(cancelledAxis) + } + .onDisappear { resetTouchRecognition() } + } + // 页面中的 DragGesture 统一引用这个固定坐标系;页面视觉平移不会 + // 改变下一帧 translation 的测量原点。 + .coordinateSpace(name: "calendarPagingInput") + .clipped() + } + + /// 吸附开始时清空本次触摸的轴判断和累计状态。 + /// + /// 当前手势稍后即使补发 onEnded,也没有轴和起始标记可供提交; + /// 系统取消则通过单独回调恢复父视图位移,不报告一次成功操作。 + private func resetTouchRecognition() { + dragAxis = nil + horizontalDragStarted = false + verticalDragStarted = false + dragStartTime = nil + } + + private var pagingGesture: some Gesture { + DragGesture( + minimumDistance: 2, + coordinateSpace: .named("calendarPagingInput") + ) + .updating($dragIsRecognized) { _, active, _ in active = true } + .onChanged { value in + if dragStartTime == nil { + dragStartTime = value.time + } + if dragAxis == nil { + let horizontal = abs(value.translation.width) + let vertical = abs(value.translation.height) + let elapsed = value.time.timeIntervalSince( + dragStartTime ?? value.time + ) + let movedEnough = max(horizontal, vertical) + >= directionDetectionDistance + let horizontalDominates = horizontal + >= vertical * directionDominanceRatio + let verticalDominates = vertical + >= horizontal * directionDominanceRatio + + // 明确的单轴移动可以立即锁定;斜向或非常轻微的移动最多 + // 观察 35ms,随后选择累计位移较大的轴,减少开始拖动时 + // 页面静止、随后突然追上手指的迟滞感。 + guard (movedEnough + && (horizontalDominates || verticalDominates)) + || elapsed >= directionDetectionWindow + else { + return + } + let lockedAxis: CalendarPagingDragAxis = horizontal > vertical + ? .horizontal + : .vertical + dragAxis = lockedAxis + onDragAxisLocked(lockedAxis) + } + + switch dragAxis { + case .horizontal: + if !horizontalDragStarted { + horizontalDragStarted = true + onHorizontalDragBegan() + } + onHorizontalDragChanged(value.translation.width) + case .vertical: + if !verticalDragStarted { + verticalDragStarted = true + onVerticalDragBegan() + } + onVerticalDragChanged(value.translation.height) + case nil: + break + } + } + .onEnded { value in + if dragAxis == .horizontal, horizontalDragStarted { + onHorizontalDragEnded(value) + } + if dragAxis == .vertical, verticalDragStarted { + onVerticalDragEnded(value) + } + resetTouchRecognition() + } + } +} + +/// 一次触摸结束时推算出的目标页和横向速度。 +struct HorizontalPageMotion { + let direction: Int + let velocity: CGFloat +} + +/// 一次吸附动画已经归一化的方向、终点和时长。 +struct HorizontalPageSnap { + let direction: Int + let target: CGFloat + let duration: Double +} + +/// 表冠一次更新对应的横向像素位移与吸附速度。 +struct CalendarCrownPageMotion { + let offsetDelta: CGFloat + let velocity: CGFloat +} + +/// 表冠横向分页的速度响应方式。 +/// +/// `balanced` 用于周/月视图;`precisionAccelerated` 用于日视图, +/// 在慢转时提供更细的像素级控制,快速旋转时提高页移倍率。 +enum CalendarCrownVelocityProfile { + case balanced + case precisionAccelerated +} + +/// 连续三页容器将越过的整页归一化后的结果。 +struct ContinuousPageOffsetUpdate { + let offset: CGFloat + let crossedPage: Int +} + +/// 在不触发隐式动画的事务中原子更新分页或滚动状态。 +/// +/// 三页容器跨过整页后需要同时切换数据基准并归一化偏移;统一使用该函数可 +/// 避免某个调用点遗漏 `transaction.animation = nil` 而产生闪动。 +func performWithoutAnimation(_ updates: () -> Void) { + var transaction = Transaction() + transaction.animation = nil + withTransaction(transaction) { + updates() + } +} + +/// 创建日、周、月视图共用的页面提交任务。 +/// +/// 动画结束后多等待 15ms,让 SwiftUI 先提交最后一帧;调用方仍用 +/// 自己的 token 拒绝已经过期的完成回调。 +func makeCalendarPageCompletionTask( + after animationDuration: Double, + action: @escaping @MainActor () -> Void +) -> Task { + Task { @MainActor in + try? await Task.sleep( + nanoseconds: UInt64( + (animationDuration + 0.015) * 1_000_000_000 + ) + ) + guard !Task.isCancelled else { return } + action() + } +} + +/// 日、周、月视图共享的表冠输入参数。 +/// +/// 三个页面只提供各自的事件处理函数;刻度范围、步长、灵敏度和系统声音 +/// 开关集中在这里,避免后续只修改其中一个页面而产生不同手感。业务触觉由 +/// `WatchHaptics` 在真正翻页或到达边界时触发,系统表冠声音保持关闭。 +struct CalendarPagingCrownInputModifier: ViewModifier { + @Binding var detent: Double + let focused: FocusState.Binding + let onChange: (DigitalCrownEvent) -> Void + let onIdle: () -> Void + + func body(content: Content) -> some View { + content + .focusable() + .focused(focused) + .digitalCrownRotation( + detent: $detent, + from: -1_000, + through: 1_000, + by: 0.25, + sensitivity: .medium, + isContinuous: true, + isHapticFeedbackEnabled: false, + onChange: onChange, + onIdle: onIdle + ) + } +} + +extension View { + /// 安装日历分页统一表冠行为,不改变调用视图的尺寸与命中区域。 + func calendarPagingCrownInput( + detent: Binding, + focused: FocusState.Binding, + onChange: @escaping (DigitalCrownEvent) -> Void, + onIdle: @escaping () -> Void + ) -> some View { + modifier( + CalendarPagingCrownInputModifier( + detent: detent, + focused: focused, + onChange: onChange, + onIdle: onIdle + ) + ) + } +} + +/// 丢弃掉帧期间积压的旧表冠位移,只消费当前绘制周期内合理的输入量。 +/// +/// `DigitalCrownEvent.offset` 可能在主线程繁忙后一次跳过很多 detent。如果把 +/// 差值全量应用,页面会在动画恢复时突然追赶。限制为两个 0.25 小刻度后, +/// 当前速度仍参与位移倍率,但历史积压不会污染下一帧。 +func frameBoundCrownDelta( + from previousOffset: Double, + to currentOffset: Double +) -> Double { + guard previousOffset.isFinite, currentOffset.isFinite else { return 0 } + return min(0.5, max(-0.5, currentOffset - previousOffset)) +} + +/// 根据页面的交互目标,把系统报告的表冠速度映射为像素位移倍率。 +func calendarPageCrownSpeedScale( + _ velocity: Double, + profile: CalendarCrownVelocityProfile +) -> Double { + let speed = abs(velocity) + switch profile { + case .balanced: + return min(2.2, max(0.95, 0.88 + speed * 0.1)) + + case .precisionAccelerated: + // smoothstep 在两端导数均为 0,不会在跨过某个速度阈值时突然跳变。 + // 慢转最低约 0.62 倍,便于逐像素对齐;速度进入中高区后更早提升 + // 到约 4.2 倍,让快速拨动时能够连续跨日,同时不改变慢转下限。 + let normalizedSpeed = min(1, max(0, (speed - 0.8) / 7.0)) + let smoothSpeed = normalizedSpeed + * normalizedSpeed + * (3 - 2 * normalizedSpeed) + return 0.62 + (4.2 - 0.62) * smoothSpeed + } +} + +/// 日、周、月横向分页相对于基础机械刻度的位移倍率。 +private let calendarHorizontalCrownMotionScale: CGFloat = 1.32 + +/// 把一个表冠小刻度换算成日、周、月分页共用的横向像素行程。 +func calendarPageCrownTickDistance( + pageWidth: CGFloat, + velocity: Double, + profile: CalendarCrownVelocityProfile +) -> CGFloat { + pageWidth + * 0.0598 + * calendarHorizontalCrownMotionScale + * calendarPageCrownSpeedScale(velocity, profile: profile) +} + +/// 统一把日、周、月视图的表冠事件换算为分页运动。 +func calendarCrownPageMotion( + delta: Double, + velocity: Double, + pageWidth: CGFloat, + distanceScale: CGFloat = 1, + velocityProfile: CalendarCrownVelocityProfile = .balanced +) -> CalendarCrownPageMotion { + let tickDistance = calendarPageCrownTickDistance( + pageWidth: pageWidth, + velocity: velocity, + profile: velocityProfile + ) * distanceScale + return CalendarCrownPageMotion( + offsetDelta: -CGFloat(delta / 0.25) * tickDistance, + velocity: CGFloat(abs(velocity) / 0.25) * tickDistance + ) +} + +/// 将完整越过一屏的偏移归一化回中间页附近。 +/// +/// 一次输入在 `frameBoundCrownDelta` 处已限制为不超过一屏, +/// 因此每次最多提交前或后一页。 +func normalizedContinuousPageOffset( + _ proposedOffset: CGFloat, + pageWidth: CGFloat +) -> ContinuousPageOffsetUpdate { + guard pageWidth > 0 else { + return ContinuousPageOffsetUpdate( + offset: proposedOffset, + crossedPage: 0 + ) + } + if proposedOffset <= -pageWidth { + return ContinuousPageOffsetUpdate( + offset: proposedOffset + pageWidth, + crossedPage: 1 + ) + } + if proposedOffset >= pageWidth { + return ContinuousPageOffsetUpdate( + offset: proposedOffset - pageWidth, + crossedPage: -1 + ) + } + return ContinuousPageOffsetUpdate( + offset: proposedOffset, + crossedPage: 0 + ) +} + +/// 日、周、月页面共用的快速吸附曲线。 +func calendarPageSnapAnimation(duration: Double) -> Animation { + // 使用无回摆系统弹簧衔接手指/表冠的当前位置;实际响应时长由剩余 + // 距离和输入速度共同决定。 + .spring( + duration: duration, + bounce: 0, + blendDuration: min(0.07, duration * 0.36) + ) +} + +/// 结合实际位移和系统预测位移,选择最接近的前/当前/后页。 +func horizontalDragMotion( + _ value: DragGesture.Value, + currentOffset: CGFloat, + pageWidth: CGFloat +) -> HorizontalPageMotion { + let projectedRemainder = value.predictedEndTranslation.width + - value.translation.width + // `predictedEndTranslation` 通常覆盖约 0.2 秒减速过程。先求带方向的 + // 释放速度,再分别处理“快甩”和“普通拖动”:快甩即使距离很短也按 + // 松手瞬间的运动趋势翻页;普通拖动则综合当前位置与短期预测位置。 + let signedVelocity = projectedRemainder / 0.2 + let velocity = abs(signedVelocity) + let flickVelocityThreshold = pageWidth * 0.9 + let direction: Int + if velocity >= flickVelocityThreshold { + direction = signedVelocity < 0 ? 1 : -1 + } else { + let projectedOffset = currentOffset + signedVelocity * 0.12 + direction = nearestPageDirection( + for: projectedOffset, + width: pageWidth, + thresholdRatio: 0.28 + ) + } + return HorizontalPageMotion(direction: direction, velocity: velocity) +} + +/// 偏移超过指定页宽比例时选择相邻页,否则回到当前页。 +/// +/// 手指横滑使用 28%,轻拖即可完成翻页;表冠停止时省略该参数,继续采用 +/// 50% 吸附线,避免轻转表冠便误切到相邻日期或周次。 +func nearestPageDirection( + for offset: CGFloat, + width: CGFloat, + thresholdRatio: CGFloat = 0.5 +) -> Int { + guard width > 0 else { return 0 } + let threshold = width * min(0.5, max(0.2, thresholdRatio)) + if offset <= -threshold { return 1 } + if offset >= threshold { return -1 } + return 0 +} + +/// 统一生成日、周、月页面的吸附参数。 +/// +/// 调用方可以在生成前按业务范围调整方向,例如周视图在学期边界把方向改为 +/// `0`。这里仅处理视觉参数,不修改日期、周次或触觉状态。 +func horizontalPageSnap( + direction: Int, + currentOffset: CGFloat, + velocity: CGFloat, + width: CGFloat +) -> HorizontalPageSnap { + let normalizedDirection = min(1, max(-1, direction)) + let target = -CGFloat(normalizedDirection) * width + return HorizontalPageSnap( + direction: normalizedDirection, + target: target, + duration: pageSnapDuration( + from: currentOffset, + to: target, + velocity: velocity, + width: width + ) + ) +} + +/// 根据剩余距离与输入速度选择短促吸附时长。 +/// +/// 系统 `onIdle` 会立即触发;响应时长随剩余距离增加、随释放速度缩短。 +/// 85–220ms 只是防止瞬移和拖尾的安全区间,并非固定播放时间。 +func pageSnapDuration( + from current: CGFloat, + to target: CGFloat, + velocity: CGFloat, + width: CGFloat +) -> Double { + let remainingRatio = min(1, abs(target - current) / max(1, width)) + let normalizedVelocity = abs(velocity) / max(1, width) + let distanceResponse = 0.085 + Double(remainingRatio) * 0.135 + let velocityReduction = min( + 0.065, + log1p(Double(normalizedVelocity)) * 0.022 + ) + return min(0.22, max(0.085, distanceResponse - velocityReduction)) +} + +/// 独立日期选择页从底部进入和退回时使用的短促弹簧。 +let monthScheduleTransitionAnimation = Animation.spring( + duration: 0.3, + bounce: 0.06, + blendDuration: 0.06 +) + +/// 周课表与教学定位共用周一零点,独立于系统地区设置的每周首日。 +func calendarWeekStart(containing date: Date) -> Date { + let calendar = Calendar.current + let day = calendar.startOfDay(for: date) + let daysSinceMonday = (calendar.component(.weekday, from: day) + 5) % 7 + return calendar.date(byAdding: .day, value: -daysSinceMonday, to: day) ?? day +} + +/// 获取系统本地化的极短星期符号,并转换为周一到周日顺序。 +/// +/// 日历月页和周页共用这一份本地化结果,避免两处各自维护星期顺序。 +func mondayFirstWeekdaySymbols() -> [String] { + var calendar = Calendar.current + calendar.locale = WatchWidgetShared.preferredLocale + let symbols = calendar.veryShortStandaloneWeekdaySymbols + guard symbols.count == 7 else { + return ["M", "T", "W", "T", "F", "S", "S"] + } + return Array(symbols[1...6]) + [symbols[0]] +} + +/// 日/周视图左上角共用的日期导航条。 +struct DateNavigationHeader: View { + let title: String + let previous: () -> Void + let next: () -> Void + var titleAction: (() -> Void)? = nil + + /// 可见标题仍为 22pt;透明命中层向下延伸约一行,避免小表盘上难以点中。 + private let visibleHeight: CGFloat = 22 + private let interactionHeight: CGFloat = 42 + private let sideInteractionWidth: CGFloat = 30 + + var body: some View { + HStack(spacing: 1) { + Image(systemName: "chevron.left") + .frame(width: 18, height: 20) + .accessibilityHidden(true) + + // 可见标题始终只是文本;是否可点击由下面的透明命中层决定。 + // 若把没有动作的周次标题做成 disabled Button,watchOS 会自动 + // 降低其亮度,导致“第几周”看起来发灰。 + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.75) + .monospacedDigit() + .frame(maxWidth: .infinity) + + Image(systemName: "chevron.right") + .frame(width: 18, height: 20) + .accessibilityHidden(true) + } + .frame(height: visibleHeight) + // 单个透明命中层覆盖标题自身并继续向下延伸,不参与可见 HStack 的 + // 布局。日、周、月三个页面因此共享完全相同的扩大触控区域。 + .overlay(alignment: .top) { + interactionOverlay + } + } + + /// 把左箭头、标题和右箭头划分为互不重叠的三块命中区域。 + private var interactionOverlay: some View { + HStack(spacing: 0) { + navigationHitTarget( + action: previous, + accessibilityLabel: "上一项" + ) + .frame(width: sideInteractionWidth) + + if let titleAction { + navigationHitTarget( + action: titleAction, + accessibilityLabel: "选择日期" + ) + .frame(maxWidth: .infinity) + } else { + Color.clear + .frame(maxWidth: .infinity) + .allowsHitTesting(false) + } + + navigationHitTarget( + action: next, + accessibilityLabel: "下一项" + ) + .frame(width: sideInteractionWidth) + } + .frame(height: interactionHeight) + } + + /// 创建不绘制任何内容、只负责扩大命中范围的按钮。 + private func navigationHitTarget( + action: @escaping () -> Void, + accessibilityLabel: LocalizedStringKey + ) -> some View { + Button(action: action) { + // watchOS 真机的 Toolbar 会把完全透明的按钮标签从 + // 命中树中剔除,结果是箭头可见但按不动。使用几乎 + // 不可见的实体填充保留真实按钮命中,不改变任何 + // 主观布局或可见颜色。 + Rectangle() + .fill(Color.white.opacity(0.001)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(accessibilityLabel) + } +} diff --git a/watchOS/Views/CourseListView.swift b/watchOS/Views/CourseListView.swift new file mode 100644 index 00000000..e886b548 --- /dev/null +++ b/watchOS/Views/CourseListView.swift @@ -0,0 +1,126 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import SwiftUI + +/// 按自然日分组的完整课程列表。 +/// +/// 列表与日视图复用 `CourseRow`,从而保证课程、考试和实验的颜色、地点及 +/// 教师/座位信息使用同一套展示规则。 +struct CourseListView: View { + @EnvironmentObject private var store: WatchScheduleStore + @State private var didPositionInitialDate = false + let onCrownInteraction: () -> Void + let onCrownInput: () -> Void + let onTouchInput: () -> Void + var alwaysAllowsTeachingBounce = false + /// 只在“课程列表·上下滑动”教学步骤中启用程序化原生滚动。 + var drivesTeachingTouchScroll = false + var inputContext = 0 + @State private var initialPositionTask: Task? + /// 正常打开列表时定位到今天或最近日程;新手教学只需要演示滚动, + /// 跳过这次跨整学期的 ScrollViewReader 定位,避免实体表为了寻找 + /// 目标 ID 在首帧同步展开大量 LazyVStack 布局。 + var positionsInitialDate = true + + /// Store 在 App 启动或课表原子替换时已经完成分组,这里只读取结果。 + private var groups: [WatchCourseDayGroup] { + store.courseListGroups + } + + var body: some View { + ScrollViewReader { scrollProxy in + InteractionAwareScrollView( + onScroll: onCrownInteraction, + onCrownInput: onCrownInput, + onTouchInput: onTouchInput, + centersShortContent: true, + // 非空列表使用 LazyVStack,必须让原生 ScrollView 直接建立 + // 完整滚动范围;空状态仍走短内容测量并保持垂直居中。 + usesLazyContentLayout: !groups.isEmpty, + alwaysAllowsBounce: alwaysAllowsTeachingBounce, + // 教学时额外旁路标记真实手指拖动的来源,但列表位移仍完全 + // 交给原生 ScrollView。实体表即使跳过 `.tracking`、直接进入 + // `.interacting`,结束后也不会被误判成表冠旋转。 + usesShortContentTouchFallback: alwaysAllowsTeachingBounce, + inputContext: inputContext, + teachingTouchScrollEffect: drivesTeachingTouchScroll + ? .nativePosition + : .disabled, + protectsInitialTopEdge: true + ) { + if groups.isEmpty { + ContentUnavailableView( + "暂无课程", + systemImage: "list.bullet" + ) + .frame(maxWidth: .infinity) + } else { + LazyVStack(alignment: .leading, spacing: 5) { + ForEach(groups) { group in + Text( + group.date, + format: .dateTime + .month() + .day() + .weekday(.wide) + ) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 5) + .padding(.top, 2) + .padding(.trailing, 32) + .id(group.date) + + ForEach(group.courses) { course in + CourseRow( + course: course, + showsInlineMetadata: true + ) + } + } + } + .padding(.horizontal, 2) + .padding(.top, 1) + } + } + .onAppear { + positionInitialDate(using: scrollProxy) + } + .onChange(of: groups.map(\.date)) { _, _ in + positionInitialDate(using: scrollProxy) + } + .onDisappear { + initialPositionTask?.cancel() + initialPositionTask = nil + } + } + } + + /// 首次进入课程列表时定位到今天;今天无课则定位到最近的日程日期。 + private func positionInitialDate(using scrollProxy: ScrollViewProxy) { + guard positionsInitialDate, + !didPositionInitialDate, + let targetDate = initialTargetDate + else { + return + } + + // 等待列表完成首轮布局后再定位;锚点放在中间,避免目标日期标题 + // 被顶部状态栏虚化遮住。 + initialPositionTask?.cancel() + initialPositionTask = Task { @MainActor in + await Task.yield() + guard !Task.isCancelled, positionsInitialDate, + groups.contains(where: { $0.date == targetDate }) else { return } + scrollProxy.scrollTo(targetDate, anchor: .center) + didPositionInitialDate = true + initialPositionTask = nil + } + } + + /// 今天优先;没有今天时按自然日距离选择最近日期,同距离时优先未来。 + private var initialTargetDate: Date? { + store.courseListInitialDate + } +} diff --git a/watchOS/Views/CourseViews.swift b/watchOS/Views/CourseViews.swift new file mode 100644 index 00000000..73e22802 --- /dev/null +++ b/watchOS/Views/CourseViews.swift @@ -0,0 +1,291 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import SwiftUI + +/// 课程列表、日视图和概览页共用的卡片。 +/// +/// 通过参数控制日期、突出样式和同行元数据,避免三个页面各自维护一份容易 +/// 分叉的课程卡片代码。 +struct CourseRow: View { + let course: WatchCourse + var showsDate = false + var isProminent = false + var showsInlineMetadata = false + + var body: some View { + HStack(alignment: .top, spacing: 8) { + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill(course.color) + .frame(width: 5) + + VStack(alignment: .leading, spacing: 4) { + if showsDate { + Text(course.startAt, format: .dateTime.month().day().weekday()) + .font(.caption2) + .foregroundStyle(.secondary) + } + + Text(course.name) + .font(isProminent ? .title3.weight(.semibold) : .headline) + .lineLimit(2) + + HStack(spacing: 4) { + Text(twentyFourHourTime(course.startAt)) + Text("–") + Text(twentyFourHourTime(course.endAt)) + } + .font(.caption.monospacedDigit()) + + if let locationSummary { + Label( + locationSummary.text, + systemImage: locationSummary.systemImage + ) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + + if isProminent, + let teacher = course.teacher, + !teacher.isEmpty + { + Label(teacher, systemImage: "person") + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, isProminent ? 9 : 5) + .padding(.horizontal, isProminent ? 9 : 7) + .background( + course.color.opacity(0.16), + in: RoundedRectangle(cornerRadius: 12, style: .continuous) + ) + } + + /// 生成“位置 · 教师”或“位置 · 座位”的单行摘要。 + /// + /// `lineLimit(1)` 与尾部截断由调用处统一负责,窄表盘不会把卡片撑宽。 + private var locationSummary: (text: String, systemImage: String)? { + course.locationSummary(includingDetails: showsInlineMetadata) + } +} + +/// 周视图课程色块对应的详情页。 +/// +/// 详情页拥有独立背景并从底部弹出;日视图和列表不创建该视图,因此不会 +/// 误触进入详情。关闭按钮仅在周视图模式启用。 +struct CourseDetailView: View { + let course: WatchCourse + var showsTopCloseButton = false + let onScroll: () -> Void + let onCrownInput: () -> Void + let onTouchInput: () -> Void + let onCloseButtonFrameChange: (CGRect) -> Void + let dismiss: () -> Void + @State private var isDismissing = false + + var body: some View { + GeometryReader { proxy in + // 在系统状态栏下方为课程内容保留一整行空白。关闭按钮会在 + // ScrollView 内用反向补偿保持原位置,因此只下移课程信息。 + let detailContentTopInset: CGFloat = 27 + let closeButtonTopInset = max(30, proxy.size.height * 0.16) + + ZStack(alignment: .topTrailing) { + Color.black + .overlay(course.color.opacity(0.08)) + .ignoresSafeArea() + + // 与概览、课程列表复用同一滚动输入桥。详情内容已有真实 + // 滚动范围,无需附加 DragGesture;系统 ScrollPhase 同时 + // 驱动画面和上报教学输入,避免检测成功但页面没有位移。 + InteractionAwareScrollView( + onScroll: onScroll, + onCrownInput: onCrownInput, + onTouchInput: onTouchInput, + requestsCrownFocus: true + ) { + ZStack(alignment: .topTrailing) { + detailContent(width: proxy.size.width) + .frame( + maxWidth: .infinity, + alignment: .leading + ) + + if showsTopCloseButton { + topCloseButton + .scaleEffect(0.82) + // 在最终缩放与滚动布局之后读取位置,教学点击 + // 动画才能始终与屏幕上真正看到的关闭按钮同心。 + .background { + WatchOnboardingFrameReader( + report: onCloseButtonFrameChange + ) + } + // 扣除外层统一的顶部 inset,使关闭按钮按自己的安全距离定位。 + .padding( + .top, + max( + 0, + closeButtonTopInset + - detailContentTopInset + ) + ) + .padding( + .trailing, + max(3, proxy.size.width * 0.018) + ) + } + } + .padding(.horizontal, max(8, proxy.size.width * 0.045)) + .padding(.top, detailContentTopInset) + // 不移动初始内容,只增加可滚动的尾部安全区,让教师、 + // 座位号等最后一行能离开圆角屏幕的底部裁切区域。 + .padding(.bottom, max(8, proxy.size.height * 0.2)) + // 详情很短时也保留一段真实滚动距离。它不改变首屏内容 + // 的起点,只让原生 ScrollView 能接收并响应数码表冠。 + .frame( + minHeight: proxy.size.height + + max(24, proxy.size.height * 0.12), + alignment: .top + ) + } + .detailTopEdgeEffectHidden() + } + .onAppear { + isDismissing = false + } + } + } + + /// 详情页可滚动的业务内容。 + /// + /// 这里只描述课程信息本身;滚动、表冠与边界皮筋全部交给外层原生 + /// ScrollView,避免内容长度接近一屏时零高度锚点无法产生实际位移。 + @ViewBuilder + private func detailContent(width: CGFloat) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text(course.name) + .font(.title3.weight(.semibold)) + .fixedSize(horizontal: false, vertical: true) + .padding( + .trailing, + showsTopCloseButton ? max(52, width * 0.27) : 0 + ) + + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(course.color) + .frame(width: max(34, width * 0.24), height: 4) + + if let kindTitle = course.kindTitle { + Label(kindTitle, systemImage: course.kindSystemImage) + .font(.caption.weight(.semibold)) + .foregroundStyle(course.color) + } + + courseDateAndTime + + if let classroom = course.classroom, !classroom.isEmpty { + Label(classroom, systemImage: "mappin.and.ellipse") + } + if let teacher = course.teacher, !teacher.isEmpty { + Label(teacher, systemImage: "person") + } + if let note = course.localizedNote { + Label(note, systemImage: "info.circle") + } + + if !showsTopCloseButton { + Button("完成", action: closeDetail) + .tint(course.color) + } + } + } + + /// 日期与 24 小时时间组合成一个语义完整的 Label。 + private var courseDateAndTime: some View { + Label { + VStack(alignment: .leading, spacing: 2) { + Text( + course.startAt, + format: .dateTime.month().day().weekday(.wide) + ) + Text( + "\(twentyFourHourTime(course.startAt)) – \(twentyFourHourTime(course.endAt))" + ) + .monospacedDigit() + } + } icon: { + Image(systemName: "clock") + .foregroundStyle(course.color) + } + } + + /// watchOS 26 的关闭按钮使用液态玻璃,旧系统使用描边样式。 + @ViewBuilder + private var topCloseButton: some View { + if #available(watchOS 26.0, *) { + closeButton + .buttonStyle(.glass) + } else { + closeButton + .buttonStyle(.bordered) + } + } + + /// 保持与刷新、模式切换按钮一致的视觉尺寸。 + /// + /// 按钮位于详情 ScrollView 内容层,随课程信息一起上下滚动。 + private var closeButton: some View { + Button(action: closeDetail) { + Image(systemName: "xmark") + .font(.caption.weight(.semibold)) + .frame(width: 20, height: 20) + } + .controlSize(.small) + .buttonBorderShape(.circle) + .fixedSize() + .contentShape(Circle()) + .disabled(isDismissing) + .accessibilityLabel("关闭课程详情") + } + + /// 先停止详情交互,下一次主线程循环再移除覆盖层。 + /// + /// 实体 Apple Watch 上,表冠焦点、ScrollView 回弹和根层转场若在同一 + /// 事务内同时变更,系统偶发会保留旧命中层,表现为需要点击两次或退出 + /// 卡顿。这里让关闭成为幂等操作,并给系统一个循环提交状态变化;不会 + /// 增加肉眼可见的延迟,也不改变详情页布局或退出动画。 + private func closeDetail() { + guard !isDismissing else { return } + isDismissing = true + WatchHaptics.selection() + DispatchQueue.main.async { + dismiss() + } + } +} + +/// 统一生成不受系统 12/24 小时偏好影响的 `HH:mm` 文本。 +private func twentyFourHourTime(_ date: Date) -> String { + WatchScheduleDate.clockText(date) +} + +private extension View { + /// 详情页只关闭顶部滚动边缘虚化,避免课程名被系统效果遮住。 + @ViewBuilder + func detailTopEdgeEffectHidden() -> some View { + if #available(watchOS 26.0, *) { + scrollEdgeEffectHidden(true, for: .top) + } else { + self + } + } +} diff --git a/watchOS/Views/DayScheduleView.swift b/watchOS/Views/DayScheduleView.swift new file mode 100644 index 00000000..46b3d16f --- /dev/null +++ b/watchOS/Views/DayScheduleView.swift @@ -0,0 +1,1539 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import SwiftUI + +/// 日视图收到表冠输入后要执行的唯一操作。 +/// +/// 先计算路由、再执行视觉更新,可以保证“课程滚动、前一日直接分页、到达 +/// 末项后连续横向翻日”三种状态互斥,也让主事件处理函数保持可审计。 +private enum DayCrownRoute { + case horizontalPage + case course(direction: Int) + case previousDayPage +} + +/// 日视图完成横向吸附后重新绑定的表冠导航轴。 +private enum DayCrownBinding: Equatable { + case horizontalPages + case verticalCourses +} + +/// 日视图横向吸附的输入来源。 +/// +/// 触摸和标题按钮在吸附期间可以暂时交出表冠焦点;表冠吸附则必须继续保留 +/// 焦点,这样用户在一次很短的停顿后继续旋转时,可以立即中断尚未完成的 +/// 吸附并从下一刻度继续翻页,而不会被动画锁住。 +private enum DayPageTransitionSource: Equatable { + case direct + case crown +} + +/// 日视图 ScrollView 的不可见顶部定位点;日期参与身份,避免三张预渲染 +/// 页面之间误用同一个滚动目标。 +private struct DayScrollTopTarget: Hashable { + let date: Date +} + +/// 日分页器实际挂载的一张轻量页面数据。 +/// +/// 这里只保存日期和已经索引好的日程,不包含任何 SwiftUI 视图。横向移动 +/// 过程中直接读取已准备的数据,也不会提前创建第四张页面。 +private struct DayPageRenderModel: Equatable { + let date: Date + let courses: [WatchCourse] +} + +/// 日视图始终只保留前、当前、后三张可见页面。 +/// +/// 跨页时三项数据按环形方式移动;已经离屏的反方向页面槽会承接预测完成的 +/// 新页面,因此视图层级和显存占用始终固定为三页。 +private struct DayPageRenderWindow: Equatable { + var previous: DayPageRenderModel + var current: DayPageRenderModel + var next: DayPageRenderModel + + func model(at relativePage: Int) -> DayPageRenderModel { + switch relativePage { + case ..<0: + previous + case 1...: + next + default: + current + } + } +} + +/// 方向确定后预热的一份“数据快照”,不是第四张渲染页面。 +/// +/// 用户反转表冠时直接覆盖旧方向预测,相当于丢弃反方向机动页。真正跨页时 +/// 才把它放入三页环形窗口的空槽,避免跨页边界临时查询和整理日程。 +@MainActor +private final class DayPagePredictionCache { + private var direction = 0 + private var model: DayPageRenderModel? + + func replace( + direction: Int, + model: DayPageRenderModel + ) { + self.direction = direction + self.model = model + } + + func contains( + direction: Int, + date: Date + ) -> Bool { + self.direction == direction && model?.date == date + } + + func take( + direction: Int, + expectedDate: Date + ) -> DayPageRenderModel? { + guard self.direction == direction, + model?.date == expectedDate + else { + return nil + } + let result = model + self.direction = 0 + model = nil + return result + } + + func removeAll() { + direction = 0 + model = nil + } +} + +/// 单日课程只负责日期切换和列表展示,课程详情入口由周视图提供。 +struct DayScheduleView: View { + @EnvironmentObject private var store: WatchScheduleStore + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.legibilityWeight) private var legibilityWeight + @Binding var selectedDate: Date + @State private var crownValue = 0.0 + @State private var lastCrownEventOffset = 0.0 + @State private var crownSession = WatchCrownTurnSession() + @State private var crownPageRamp = CalendarCrownPageRamp() + @State private var continuousDayNavigation = false + /// 上一个已给出触觉反馈的卡片索引;日视图本身不选中卡片。 + @State private var feedbackCourseIndex = 0 + @State private var courseScrollPosition = 0.0 + @State private var courseContentOffset: CGFloat = 0 + @State private var verticalTouchStartOffset: CGFloat = 0 + @State private var dayViewportHeight: CGFloat = 1 + @State private var horizontalPageOffset: CGFloat = 0 + /// 表冠输入只更新目标值;显示值由刷新循环逐帧追踪。 + @State private var horizontalTargetPageOffset: CGFloat = 0 + @State private var horizontalFrameTask: Task? + @State private var horizontalFrameToken = 0 + @State private var horizontalTouchStartOffset: CGFloat = 0 + @State private var horizontalPageWidth: CGFloat = 1 + @State private var horizontalCrownVelocity: CGFloat = 0 + @State private var inputRecognitionResetToken = 0 + @State private var pageTransitionToken = 0 + @State private var pageTransitionTask: Task? + @State private var pageTransitionSource: DayPageTransitionSource? + @State private var pendingSnapDirection = 0 + @State private var crownIdleCoordinator = CalendarCrownIdleCoordinator() + @State private var verticalMomentumTask: Task? + @State private var verticalMomentumToken = 0 + @State private var pageTransitionInFlight = false + @State private var courseLayoutTracker = DayCourseLayoutTracker() + /// SwiftUI 永远只渲染这个三页窗口。 + @State private var dayPageWindow: DayPageRenderWindow? + /// 预测数据保存在非观察型引用中,预热本身不会触发页面重绘。 + @State private var dayPagePrediction = DayPagePredictionCache() + /// 日期格式化只在日期或语言变化时执行,避免横向位移每帧重复创建格式器。 + @State private var navigationTitle = "" + @FocusState private var crownFocused: Bool + let isDatePickerPresented: Bool + let onDatePickerRequested: (Date) -> Void + /// 日视图轻点内容时显式通知根视图显示两个悬浮按钮。 + let onContentTap: () -> Void + let onCrownInteraction: () -> Void + /// 表冠确认为真实输入时旁路通知教学层。 + let onCrownInput: () -> Void + /// 表冠连续旋转并真正跨过一整页时通知教学层。 + let onCrownPageInput: () -> Void + /// 单一触摸层锁定方向时,只通知教学说明隐去,不抢占真实拖动。 + let onTouchInputBegan: () -> Void + /// 真实拖动结束后,把已经执行的轴向操作旁路交给教学验证。 + let onSwipeInput: (CalendarPagingDragAxis) -> Void + /// 顶部左箭头的真实点击旁路通知。 + let onHeaderPreviousTap: () -> Void + /// 顶部右箭头的真实点击旁路通知。 + let onHeaderNextTap: () -> Void + + /// 日内纵向浏览使用的表冠位移倍率;横向翻页使用独立倍率。 + private let verticalCrownMotionScale = 0.175 + + /// 一张完整课程卡片对应的表冠原始刻度基准。 + private let cardScrollThreshold = 0.75 + + /// `DaySchedulePageContent` 内课程卡片之间的真实间距。 + private let courseCardSpacing: CGFloat = 5 + + /// 当前选中日期内开始的全部日程。 + private var courses: [WatchCourse] { + if let dayPageWindow, + dayPageWindow.current.date + == Calendar.current.startOfDay(for: selectedDate) + { + return dayPageWindow.current.courses + } + return store.courses(on: selectedDate) + } + + var body: some View { + CalendarHorizontalPager( + pageOffset: horizontalPageOffset, + interactionResetToken: inputRecognitionResetToken, + pageIdentity: dayDate, + page: dayPage, + onViewportWidthChange: updateDayViewportWidth, + onViewportHeightChange: { dayViewportHeight = max(1, $0) }, + onHorizontalDragBegan: beginDayHorizontalDrag, + onHorizontalDragChanged: updateDayHorizontalDrag, + onHorizontalDragEnded: finishDayHorizontalDrag, + onVerticalDragBegan: beginDayVerticalDrag, + onVerticalDragChanged: updateDayVerticalDrag, + onVerticalDragEnded: finishDayVerticalDrag, + onDragAxisLocked: { _ in onTouchInputBegan() }, + onDragCancelled: { axis in + guard !pageTransitionInFlight else { return } + if axis == .horizontal { + cancelHorizontalFrameSmoothing() + settleDayPage(direction: 0, velocity: 0) + } else { + settleDayContentAfterTouch() + } + } + ) + // 表冠一旦接管横向分页,三页内容子树进入“分页独占”事务:卡片补间、 + // 边界回弹和数据替换附带的隐式动画全部关闭,只保留停止旋转后由 + // `settleDayPage` 显式创建的吸附动画。这样持续旋转时不会有其他动画 + // 与 `horizontalPageOffset` 争用同一帧预算。 + .transaction { transaction in + if continuousDayNavigation && !pageTransitionInFlight { + transaction.animation = nil + } + } + .onAppear { + prepareDayPageWindow(force: true) + crownFocused = true + lastCrownEventOffset = crownValue + feedbackCourseIndex = 0 + configureDayCourseLayoutCache() + courseLayoutTracker.resumePersistence() + updateDayNavigationTitle() + } + .onDisappear { + crownFocused = false + // 同时废弃已排入主队列的聚焦回调;取消 Task 不能撤回 DispatchQueue 回调。 + pageTransitionToken &+= 1 + courseLayoutTracker.suspendPersistence() + crownIdleCoordinator.cancel() + pageTransitionTask?.cancel() + cancelHorizontalFrameSmoothing() + cancelDayVerticalMomentum() + } + .onChange(of: isDatePickerPresented) { _, isPresented in + if isPresented { + cancelHorizontalFrameSmoothing() + cancelDayVerticalMomentum() + crownIdleCoordinator.cancel() + crownFocused = false + } else { + crownSession.reset() + lastCrownEventOffset = crownValue + crownFocused = true + } + } + // 课表索引变化时重新校正日内位置;普通跨日已经由 moveDay 完成重置。 + .onChange(of: store.renderCacheRevision) { _, _ in + prepareDayPageWindow(force: true) + recalculateCurrentDayCourseBounds() + } + .onChange(of: store.preferredLanguageIdentifier) { _, _ in + configureDayCourseLayoutCache() + updateDayNavigationTitle() + } + .onChange(of: dynamicTypeSize) { _, _ in + configureDayCourseLayoutCache() + } + .onChange(of: legibilityWeight) { _, _ in + configureDayCourseLayoutCache() + } + .onChange(of: selectedDate) { _, _ in + prepareDayPageWindow() + updateDayNavigationTitle() + } + .toolbar { + if !isDatePickerPresented { + ToolbarItem(placement: .topBarLeading) { + DateNavigationHeader( + title: navigationTitle, + previous: { + onHeaderPreviousTap() + requestDayPage(-1) + }, + next: { + onHeaderNextTap() + requestDayPage(1) + }, + titleAction: requestDatePicker + ) + .frame(width: 116) + .offset(y: -10) + } + } + } + } + + /// 日期标题不是逐帧动画数据;只在依赖真正变化时格式化一次。 + private func updateDayNavigationTitle() { + navigationTitle = selectedDate.formatted( + .dateTime + .month() + .day() + .weekday(.short) + .locale(WatchWidgetShared.preferredLocale) + ) + } + + /// 视口宽度只在表盘尺寸变化时更新;同时用它选择对应的持久化高度缓存。 + private func updateDayViewportWidth(_ width: CGFloat) { + let normalizedWidth = max(1, width) + horizontalPageWidth = normalizedWidth + configureDayCourseLayoutCache(for: normalizedWidth) + } + + /// 卡片高度只在相同课表版本、语言和内容宽度下复用。 + private func configureDayCourseLayoutCache( + for width: CGFloat? = nil + ) { + let contentWidth = Int( + max(1, width ?? horizontalPageWidth).rounded() + ) + let scheduleIdentity: String + if let snapshot = store.snapshot { + // 当天/14 天覆盖已经可以改变卡片,不能继续只用旧整学期版本。 + scheduleIdentity = [ + String(snapshot.schemaVersion), + String(snapshot.freshnessStamp), + String(snapshot.generatedAtEpochMs), + String(snapshot.courses.count), + ].joined(separator: "-") + } else { + scheduleIdentity = "empty" + } + let signature = [ + "v\(DayCourseLayoutCacheConfiguration.schemaVersion)", + scheduleIdentity, + store.preferredLanguageIdentifier, + String(contentWidth), + String(describing: dynamicTypeSize), + String(describing: legibilityWeight), + ].joined(separator: "|") + courseLayoutTracker.configure(signature: signature) + } + + /// 入口只负责暂停日视图输入并请求根容器展示独立选择页面。 + private func requestDatePicker() { + guard !isDatePickerPresented, !pageTransitionInFlight else { return } + cancelHorizontalFrameSmoothing() + cancelDayVerticalMomentum() + crownIdleCoordinator.cancel() + crownFocused = false + onCrownInteraction() + onDatePickerRequested(selectedDate) + } + + /// 用一次 Store 查询构造一张可复用的日页面数据。 + private func makeDayPageModel(for date: Date) -> DayPageRenderModel { + let normalizedDate = Calendar.current.startOfDay(for: date) + return DayPageRenderModel( + date: normalizedDate, + courses: store.courses(on: normalizedDate) + ) + } + + /// 围绕指定日期生成固定三页窗口;异常回退也必须使用真实落地日期。 + private func makeDayPageWindow( + centeredOn date: Date + ) -> DayPageRenderWindow { + let centerDate = Calendar.current.startOfDay(for: date) + let calendar = Calendar.current + let previousDate = calendar.date( + byAdding: .day, + value: -1, + to: centerDate + ) ?? centerDate + let nextDate = calendar.date( + byAdding: .day, + value: 1, + to: centerDate + ) ?? centerDate + return DayPageRenderWindow( + previous: makeDayPageModel(for: previousDate), + current: makeDayPageModel(for: centerDate), + next: makeDayPageModel(for: nextDate) + ) + } + + /// 首次进入、外部选日或课表版本变化时一次性准备三张可见页面。 + private func prepareDayPageWindow(force: Bool = false) { + let centerDate = Calendar.current.startOfDay(for: selectedDate) + if !force, dayPageWindow?.current.date == centerDate { + return + } + dayPagePrediction.removeAll() + dayPageWindow = makeDayPageWindow(centeredOn: centerDate) + } + + /// 表冠或手指一确定翻页方向,就覆盖旧方向并预热该方向的下一张数据。 + /// + /// 预测结果不挂载 View;只有跨过完整一页时,才进入三页环形窗口。 + private func preparePredictedDayPage(direction: Int) { + let normalizedDirection = min(1, max(-1, direction)) + guard normalizedDirection != 0 else { return } + if dayPageWindow == nil { + prepareDayPageWindow(force: true) + } + guard let centerDate = dayPageWindow?.current.date, + let predictedDate = Calendar.current.date( + byAdding: .day, + value: normalizedDirection * 2, + to: centerDate + ) + else { + return + } + guard !dayPagePrediction.contains( + direction: normalizedDirection, + date: predictedDate + ) else { + return + } + dayPagePrediction.replace( + direction: normalizedDirection, + model: makeDayPageModel(for: predictedDate) + ) + } + + /// 跨页时环形复用三张页面,并立即为继续旋转准备下一份数据。 + private func advanceDayPageWindow( + direction: Int, + landingDate: Date + ) { + let normalizedDirection = min(1, max(-1, direction)) + let normalizedLandingDate = Calendar.current.startOfDay( + for: landingDate + ) + guard normalizedDirection != 0 else { return } + guard let currentWindow = dayPageWindow else { + dayPagePrediction.removeAll() + dayPageWindow = makeDayPageWindow(centeredOn: normalizedLandingDate) + preparePredictedDayPage(direction: normalizedDirection) + return + } + + let landingModel = normalizedDirection > 0 + ? currentWindow.next + : currentWindow.previous + guard landingModel.date == normalizedLandingDate, + let incomingDate = Calendar.current.date( + byAdding: .day, + value: normalizedDirection, + to: normalizedLandingDate + ) + else { + // 外部选日或同步恰好与翻页同帧发生时,不旋转旧窗口;直接围绕 + // 真正的落地日期重建三页,避免短暂闪回旧日期。 + dayPagePrediction.removeAll() + dayPageWindow = makeDayPageWindow(centeredOn: normalizedLandingDate) + preparePredictedDayPage(direction: normalizedDirection) + return + } + + let incomingModel = dayPagePrediction.take( + direction: normalizedDirection, + expectedDate: incomingDate + ) ?? makeDayPageModel(for: incomingDate) + + if normalizedDirection > 0 { + dayPageWindow = DayPageRenderWindow( + previous: currentWindow.current, + current: currentWindow.next, + next: incomingModel + ) + } else { + dayPageWindow = DayPageRenderWindow( + previous: incomingModel, + current: currentWindow.previous, + next: currentWindow.current + ) + } + + // 下一次跨页所需的机动数据仍只是一份轻量模型,不增加可见页面。 + preparePredictedDayPage(direction: normalizedDirection) + } + + /// 返回三页窗口中的稳定模型;初始化首帧才会走同步回退。 + private func dayPageModel(_ relativePage: Int) -> DayPageRenderModel { + if let dayPageWindow { + return dayPageWindow.model(at: relativePage) + } + let date = Calendar.current.date( + byAdding: .day, + value: relativePage, + to: selectedDate + ) ?? selectedDate + return makeDayPageModel(for: date) + } + + /// 预先渲染前一天、当天和后一天;三页共用同一个横向触摸检测层。 + /// + /// 同一触摸层先锁定横向或纵向:横向修改三页容器的 `x` 偏移,纵向 + /// 修改课程栈与表冠共用的内容偏移,两条路径在一次触摸中保持互斥。 + @ViewBuilder + private func dayPage(_ relativePage: Int) -> some View { + let model = dayPageModel(relativePage) + + GeometryReader { viewport in + ZStack(alignment: .bottomLeading) { + DaySchedulePageContent( + date: model.date, + courses: model.courses, + viewportSize: viewport.size, + courseOffset: relativePage == 0 + ? courseContentOffset + : 0, + languageIdentifier: store.preferredLanguageIdentifier, + onCourseLayoutMetricsChange: updateDayCourseLayout + ) + .equatable() + + // 三页使用完全相同的内容层级,只给中间页添加表冠观察器。 + // 页面身份从右侧移动到中间时,课程 ScrollView 因而能原样 + // 保留,不会因条件分支结构改变而再次销毁重建。 + if relativePage == 0 { + dayCrownObserver() + } + } + // 固定触摸层铺满状态栏以下的整个日视图,而不是只使用课程卡片 + // 的视觉边界。这样轻点任意黑色空白区域也能唤醒悬浮按钮。 + .frame( + width: viewport.size.width, + height: viewport.size.height + ) + .contentShape(Rectangle()) + .highPriorityGesture( + TapGesture().onEnded { + guard relativePage == 0 else { return } + onContentTap() + settleDayContentAfterTouch() + } + ) + } + .allowsHitTesting(relativePage == 0) + } + + /// 返回三页容器中某一相对位置对应的真实日期。 + /// + /// 分页器使用该日期作为页面身份;跨日时,已经显示完整的相邻页会从 + /// “后一页”移动成“当前页”,而不是销毁后重新创建,从而消除有课页面 + /// 刚进入屏幕时的卡片补绘和明显卡顿。 + private func dayDate(_ relativePage: Int) -> Date { + dayPageModel(relativePage).date + } + + /// 透明节点独占日视图的表冠焦点,以便区分慢转滚动和快转切日。 + private func dayCrownObserver() -> some View { + Color.clear + .frame(width: 1, height: 1) + .calendarPagingCrownInput( + detent: $crownValue, + focused: $crownFocused, + onChange: { event in + handleDayCrownChange(event) + }, + onIdle: { + handleDayCrownIdle() + } + ) + .accessibilityHidden(true) + } + + /// 将表冠输入转换为“先滚课程、到达末项后直接切日”的两阶段操作。 + /// + /// 数值增加先向后浏览课程,到达最后一项便进入下一日横向分页;数值 + /// 减少先向前浏览课程,到达第一项后直接进入前一日横向分页。 + private func handleDayCrownChange(_ event: DigitalCrownEvent) { + guard !isDatePickerPresented, event.offset.isFinite, event.velocity.isFinite else { return } + let delta = frameBoundCrownDelta(from: lastCrownEventOffset, to: event.offset) + lastCrownEventOffset = event.offset + guard abs(delta) > .ulpOfOne else { return } + // 新刻度是“仍在旋转”的唯一可靠信号;先取消可能由短暂 onIdle + // 排队的吸附,保证连续翻页期间绝不会撞上收口动画。 + crownIdleCoordinator.cancel() + courseLayoutTracker.suspendPersistence() + // 表冠接管时立即停止手指松开后的惯性,避免两种输入同时修改 + // `courseContentOffset` 而造成位置跳动。 + cancelDayVerticalMomentum() + + // `onIdle` 在实体表很慢的连续旋转中偶尔会过早到达。若它已经启动 + // 表冠吸附,新刻度必须先原子完成那一页并解除动画锁,再继续消费本次 + // 位移;触摸和标题按钮发起的过渡仍保持不可打断。 + guard resumeDayCrownFromPendingSnapIfNeeded() else { return } + guard let update = crownSession.register(delta: delta) else { return } + + onCrownInput() + onCrownInteraction() + crownFocused = true + prepareDayCrownSession(update) + + switch dayCrownRoute(for: update.direction) { + case .horizontalPage: + activateHorizontalDayNavigation() + applyHorizontalCrownDelta(delta, velocity: event.velocity) + case .previousDayPage: + beginPreviousDayHorizontalNavigation( + delta: delta, + velocity: event.velocity + ) + case let .course(direction): + updateCourseSelectionPreview( + direction: direction, + delta: delta, + velocity: event.velocity + ) + } + crownIdleCoordinator.scheduleFallback { + settleDayCrownAfterInput() + } + } + + /// 为新一轮旋转或方向反转准备日视图状态。 + /// + /// 横向翻日一旦开始,同一轮旋转即使反向也仍由横向分页器接管。 + /// 日内浏览反向时仅清除越界拉动,连续的卡片位置不重置,因而不会 + /// 在三项以上课程中因 anchor 切换出现突变。 + private func prepareDayCrownSession(_ update: WatchCrownTurnUpdate) { + crownPageRamp.register(update) + if update.startsNewSession { + if abs(horizontalPageOffset) < 0.5 { + // 零或一项日程没有纵向卡片导航的必要,表冠直接用于翻日。 + if courses.count <= 1 { + activateHorizontalDayNavigation() + } else { + continuousDayNavigation = false + } + } + } + + } + + /// 根据当前页面状态选择本次表冠事件的处理路径。 + private func dayCrownRoute(for direction: Int) -> DayCrownRoute { + if continuousDayNavigation { + return .horizontalPage + } + let lastPosition = Double(max(0, courses.count - 1)) + if direction > 0, courseScrollPosition < lastPosition { + return .course(direction: direction) + } + if direction < 0, courseScrollPosition > 0 { + return .course(direction: direction) + } + return direction < 0 ? .previousDayPage : .horizontalPage + } + + /// 把表冠原始刻度连续映射到当日卡片轴。 + /// + /// 该路径只更新一个连续的内容位移,不在滚动途中切换 + /// `ScrollView` 锚点,因此卡片高度不同时也能线性过渡。 + private func updateCourseSelectionPreview( + direction: Int, + delta: Double, + velocity: Double + ) { + guard courses.count > 1 else { return } + let previousPosition = courseScrollPosition + let positionDelta = abs(delta) / cardScrollThreshold + * verticalCrownMotionScale + * Double(direction) + let lastPosition = Double(courses.count - 1) + let nextPosition = min( + lastPosition, + max(0, previousPosition + positionDelta) + ) + courseScrollPosition = nextPosition + + let nextSelectedIndex = Int(nextPosition.rounded()) + if nextSelectedIndex != feedbackCourseIndex { + feedbackCourseIndex = nextSelectedIndex + WatchHaptics.selection() + } + + let nextOffset = courseLayoutTracker.contentOffset( + for: nextPosition, + courses: courses, + spacing: courseCardSpacing + ) + let entersHorizontalNavigation = direction > 0 + && nextPosition >= lastPosition + if entersHorizontalNavigation { + // 切换横向轴的这一帧直接结束最后一段纵向补间。否则 45ms 的 + // 卡片动画会与三页容器同时运行,在实体表上表现为一次纵向回弹。 + performWithoutAnimation { + courseContentOffset = nextOffset + verticalTouchStartOffset = nextOffset + } + } else { + // 只在仍处于纵向浏览时用极短补间填充相邻 detent 间隔;横向会话 + // 从不创建这类动画,也不会积压历史目标。 + withAnimation(.linear(duration: 0.045)) { + courseContentOffset = nextOffset + } + } + + guard entersHorizontalNavigation else { return } + + // 到达末张卡片即切换导航轴,不再继续制造纵向越界位移。 + // 当前刻度如果越过末项,未被纵向消费的部分立即传给横向分页, + // 因而持续旋转时不会在两种模式之间产生停顿。 + let consumedPosition = max(0, lastPosition - previousPosition) + let consumedDelta = consumedPosition + * cardScrollThreshold + / verticalCrownMotionScale + let horizontalRemainder = max(0, abs(delta) - consumedDelta) + activateHorizontalDayNavigation() + if horizontalRemainder > .ulpOfOne { + applyHorizontalCrownDelta( + horizontalRemainder, + velocity: velocity + ) + } + } + + /// 已在第一项时反向转动表冠,立即切换到横向翻日前一日。 + private func beginPreviousDayHorizontalNavigation( + delta: Double, + velocity: Double + ) { + activateHorizontalDayNavigation() + applyHorizontalCrownDelta(delta, velocity: velocity) + } + + /// 统一进入日视图横向分页状态。 + /// + /// 当前页保持纵向停留位置随页面一起移出屏幕;日期真正提交后, + /// `moveDay` 才会无动画把新页面重置到首张卡片。 + private func activateHorizontalDayNavigation() { + guard !continuousDayNavigation else { return } + cancelDayVerticalMomentum() + // 横向会话独占 `horizontalPageOffset`。固定纵向模型值并暂停持久化, + // 防止惯性、边界回弹或延迟写盘在翻页帧中触发额外布局工作。 + let fixedCourseOffset = courseLayoutTracker.contentOffset( + for: courseScrollPosition, + courses: courses, + spacing: courseCardSpacing + ) + performWithoutAnimation { + courseContentOffset = fixedCourseOffset + verticalTouchStartOffset = fixedCourseOffset + } + courseLayoutTracker.suspendPersistence() + continuousDayNavigation = true + } + + /// 以自然日为单位提交已完成的横向翻页。 + private func moveDay( + _ amount: Int, + preservesHorizontalNavigation: Bool = false + ) { + cancelDayVerticalMomentum() + let nextDate = Calendar.current.date( + byAdding: .day, + value: amount, + to: selectedDate + ) ?? selectedDate + guard nextDate != selectedDate else { return } + // 当前页完整离屏或吸附到目标页后才提交日期;此刻无动画清除旧页 + // 的纵向位置,使新页面从首张卡片上沿开始。 + feedbackCourseIndex = 0 + courseScrollPosition = 0 + courseContentOffset = 0 + if !preservesHorizontalNavigation { + continuousDayNavigation = false + } + advanceDayPageWindow( + direction: amount, + landingDate: nextDate + ) + // 手指、表冠和顶部按钮都在页面真正提交时反馈一次。 + WatchHaptics.navigation(amount) + selectedDate = nextDate + } + + /// 日期数据变化后清理旧卡片位置,从新列表顶部重新采样。 + private func recalculateCurrentDayCourseBounds() { + cancelDayVerticalMomentum() + feedbackCourseIndex = 0 + courseScrollPosition = 0 + courseContentOffset = 0 + configureDayCourseLayoutCache() + // 连续横向翻页跨过整屏时,日期和课程 ID 会同时变化;保留表冠 + // 会话才能让未停下的旋转继续翻后续日期。普通同步替换则重置会话。 + if !continuousDayNavigation { + crownSession.reset() + } + } + + /// 接收前、中、后三页的卡片高度,仅写入非观察型记录器。 + private func updateDayCourseLayout( + _ metrics: DayCourseLayoutMetrics + ) { + courseLayoutTracker.update(metrics: metrics) + } + + /// 顶部按钮与触摸、表冠共用相同的平移和吸附动画。 + private func requestDayPage(_ amount: Int) { + guard !isDatePickerPresented, !pageTransitionInFlight else { return } + cancelDayVerticalMomentum() + crownIdleCoordinator.cancel() + crownFocused = true + onCrownInteraction() + settleDayPage(direction: amount, velocity: horizontalPageWidth * 2.2) + } + + private func beginDayHorizontalDrag() { + guard !pageTransitionInFlight else { return } + cancelHorizontalFrameSmoothing() + courseLayoutTracker.suspendPersistence() + cancelDayVerticalMomentum() + crownIdleCoordinator.cancel() + continuousDayNavigation = false + horizontalTouchStartOffset = horizontalPageOffset + horizontalTargetPageOffset = horizontalPageOffset + crownFocused = true + onCrownInteraction() + } + + /// 纵向触摸从表冠当前停留的内容偏移开始,不重新建立滚动锚点。 + /// + /// 触摸与表冠由此共享 `courseContentOffset` 和连续课程位置;表冠已经把 + /// 卡片移动到中间时,手指可以直接从该位置向任意方向继续拖动。单项 + /// 日程也进入这条路径,从而获得与多项日程一致的跟手、惯性和回弹; + /// 只有表冠继续保持单项日程直接横向翻日。 + private func beginDayVerticalDrag() { + guard !pageTransitionInFlight, !courses.isEmpty else { return } + courseLayoutTracker.suspendPersistence() + cancelDayVerticalMomentum() + crownIdleCoordinator.cancel() + continuousDayNavigation = false + verticalTouchStartOffset = courseContentOffset + crownSession.reset() + lastCrownEventOffset = crownValue + onCrownInteraction() + } + + /// 手指纵向移动直接修改表冠使用的同一内容合成位移。 + private func updateDayVerticalDrag(_ translation: CGFloat) { + guard !pageTransitionInFlight, !courses.isEmpty else { return } + let nextOffset = verticalTouchStartOffset + translation + performWithoutAnimation { + courseContentOffset = nextOffset + } + synchronizeCoursePosition(with: nextOffset) + onCrownInteraction() + } + + /// 触摸结束后按松手末速度继续滑动,并逐帧减速。 + /// + /// 末速度由系统的预测终点反推,惯性阶段仍然写入触摸与表冠共用的 + /// `courseContentOffset`。速度较小则直接执行边界收口。 + private func finishDayVerticalDrag(_ value: DragGesture.Value) { + guard !pageTransitionInFlight, !courses.isEmpty else { return } + onSwipeInput(.vertical) + let velocity = verticalDragReleaseVelocity(value) + let restingRange = dayTouchRestingRange() + let isOutsideRestingRange = !restingRange.contains( + courseContentOffset + ) + // 已经越界时即使末速度很低,也交给同一套边界物理过程,避免直接 + // 切换成 SwiftUI 弹簧而让速度在松手瞬间反号。 + guard abs(velocity) >= dayVerticalMomentumMinimumVelocity + || isOutsideRestingRange + else { + settleDayContentAfterTouch() + return + } + startDayVerticalMomentum(initialVelocity: velocity) + } + + /// 启动日视图纵向惯性,并使用指数摩擦使速度连续衰减。 + /// + /// 正常区间内只使用摩擦减速;越过首项顶边或末项贴底位置后,改用 + /// 弹力与阻尼共同减速。内容会先沿松手方向继续移动,速度降为零后再 + /// 自然反向;回程重新穿过边界时直接落位,不叠加第二段动画。 + private func startDayVerticalMomentum(initialVelocity: CGFloat) { + cancelDayVerticalMomentum() + verticalMomentumToken += 1 + let token = verticalMomentumToken + let initialOffset = courseContentOffset + let restingRange = dayTouchRestingRange() + + verticalMomentumTask = Task { @MainActor in + var offset = initialOffset + var velocity = initialVelocity + // 惯性只依赖经过时间,系统校时不应改变相邻帧的物理步长。 + var previousFrame = ProcessInfo.processInfo.systemUptime + + while !Task.isCancelled, token == verticalMomentumToken { + try? await Task.sleep(nanoseconds: 16_000_000) + guard !Task.isCancelled, token == verticalMomentumToken else { + return + } + + let currentFrame = ProcessInfo.processInfo.systemUptime + let elapsed = currentFrame - previousFrame + previousFrame = currentFrame + // 实体表掉帧时只消费一帧上限,避免恢复后追赶积压位移。 + let deltaTime = min( + dayVerticalMomentumMaximumFrameDuration, + max(0.001, elapsed) + ) + let boundary = dayVerticalMomentumBoundary( + for: offset, + restingRange: restingRange + ) + if let boundary { + let displacement = offset - boundary + let acceleration = -dayVerticalMomentumSpringStiffness + * displacement + - dayVerticalMomentumSpringDamping * velocity + let updatedVelocity = velocity + + acceleration * deltaTime + let isMovingOutward = displacement * velocity > 0 + // 离散帧不能直接把向外速度改成向内速度;至少保留一帧 + // 的零速转折点,视觉上才是“顺势减速—停住—反向”。 + if isMovingOutward, + velocity * updatedVelocity < 0 + { + velocity = 0 + } else { + velocity = updatedVelocity + } + } else { + velocity *= CGFloat( + exp(-dayVerticalMomentumFriction * deltaTime) + ) + } + + let nextOffset = offset + velocity * deltaTime + // 越界内容已经反向并重新进入正常区间:在穿过边界的这一帧 + // 精确停到边界,避免继续冲入内容区后再出现一次方向突变。 + if let boundary, + (offset - boundary) * (nextOffset - boundary) <= 0 + { + performWithoutAnimation { + courseContentOffset = boundary + } + synchronizeCoursePosition(with: boundary) + verticalTouchStartOffset = boundary + verticalMomentumTask = nil + settleDayContentAfterTouch() + return + } + + offset = nextOffset + performWithoutAnimation { + courseContentOffset = offset + } + synchronizeCoursePosition(with: offset) + + let remainingBoundary = dayVerticalMomentumBoundary( + for: offset, + restingRange: restingRange + ) + if abs(velocity) < dayVerticalMomentumStopVelocity, + remainingBoundary == nil + { + verticalMomentumTask = nil + settleDayContentAfterTouch() + return + } else if let remainingBoundary, + abs(offset - remainingBoundary) < 0.5, + abs(velocity) < dayVerticalMomentumStopVelocity + { + performWithoutAnimation { + courseContentOffset = remainingBoundary + } + synchronizeCoursePosition(with: remainingBoundary) + verticalTouchStartOffset = remainingBoundary + verticalMomentumTask = nil + settleDayContentAfterTouch() + return + } + } + } + } + + /// 取消尚未结束的纵向惯性;Token 同时拒绝已经排队的旧帧。 + private func cancelDayVerticalMomentum() { + verticalMomentumToken += 1 + verticalMomentumTask?.cancel() + verticalMomentumTask = nil + } + + /// 轻点或纵向拖动结束时,共用同一套触摸收口逻辑。 + private func settleDayContentAfterTouch() { + guard !pageTransitionInFlight, !courses.isEmpty else { return } + cancelDayVerticalMomentum() + crownIdleCoordinator.cancel() + let restingOffset = dayTouchRestingOffset(courseContentOffset) + synchronizeCoursePosition(with: restingOffset) + if abs(restingOffset - courseContentOffset) > 0.5 { + withAnimation( + .spring( + // 稍长的响应时间配合更高阻尼,让越界内容平顺回到 + // 正常停留范围,避免松手后立即“弹紧”的突兀感。 + response: 0.46, + dampingFraction: 0.84, + blendDuration: 0.08 + ) + ) { + courseContentOffset = restingOffset + } + } else { + courseContentOffset = restingOffset + } + verticalTouchStartOffset = restingOffset + crownSession.reset() + lastCrownEventOffset = crownValue + crownFocused = true + courseLayoutTracker.resumePersistence() + // 这里只负责纵向内容收口,不改变根页面按钮可见性。拖动路径在 + // begin/update 阶段已经调用过 onCrownInteraction;轻点路径则会先 + // 调用 onContentTap 显示按钮。若在这里再次上报“滚动交互”,两项及 + // 以上课程的页面就会出现“刚显示又立即隐藏”的假性点击失效。 + } + + /// 把手指修改后的像素偏移同步回表冠使用的连续课程索引。 + private func synchronizeCoursePosition(with contentOffset: CGFloat) { + let position = courseLayoutTracker.position( + forContentOffset: contentOffset, + courses: courses, + spacing: courseCardSpacing + ) + courseScrollPosition = position + feedbackCourseIndex = Int(position.rounded()) + } + + /// 返回触摸放手后允许停留的纵向偏移范围。 + private func dayTouchRestingOffset(_ proposedOffset: CGFloat) -> CGFloat { + let range = dayTouchRestingRange() + return min(range.upperBound, max(range.lowerBound, proposedOffset)) + } + + /// 返回首项顶边与末项贴底位置构成的正常触摸停留区间。 + private func dayTouchRestingRange() -> ClosedRange { + let protectedTopInset = max(26, dayViewportHeight * 0.25) + // 末张卡片下方保留约一行正文高度,避免回弹后紧贴圆角屏幕边缘。 + // 比例约束兼顾不同表径,同时限制范围,防止大表盘留白过多。 + let bottomLineInset = min( + 20, + max(16, dayViewportHeight * 0.08) + ) + let contentHeight = courseLayoutTracker.contentHeight( + courses: courses, + spacing: courseCardSpacing + ) + let bottomAlignedOffset = min( + CGFloat.zero, + dayViewportHeight + - protectedTopInset + - bottomLineInset + - contentHeight + ) + return bottomAlignedOffset...0 + } + + private func updateDayHorizontalDrag(_ translation: CGFloat) { + guard !pageTransitionInFlight else { return } + // 一次触摸内始终保持页面身份不变,并让容器位移与手指物理位移 + // 逐点相等;不缩放、不封顶,也不在手指按住时提前提交日期。 + let nextOffset = horizontalTouchStartOffset + translation + let direction = nextOffset < 0 ? 1 : (nextOffset > 0 ? -1 : 0) + if direction != 0 { + preparePredictedDayPage(direction: direction) + } + horizontalTargetPageOffset = nextOffset + horizontalPageOffset = nextOffset + } + + private func finishDayHorizontalDrag(_ value: DragGesture.Value) { + guard !pageTransitionInFlight else { return } + cancelHorizontalFrameSmoothing() + onSwipeInput(.horizontal) + let motion = horizontalDragMotion( + value, + currentOffset: horizontalPageOffset, + pageWidth: horizontalPageWidth + ) + settleDayPage(direction: motion.direction, velocity: motion.velocity) + } + + /// 将表冠刻度转换成横向像素;快速旋转提高每刻度位移,慢转便于精确停页。 + private func applyHorizontalCrownDelta( + _ delta: Double, + velocity: Double + ) { + guard !pageTransitionInFlight else { return } + let motion = calendarCrownPageMotion( + delta: delta, + velocity: velocity, + pageWidth: horizontalPageWidth, + distanceScale: crownPageRamp.distanceScale, + velocityProfile: .precisionAccelerated + ) + horizontalCrownVelocity = motion.velocity + let direction = motion.offsetDelta < 0 ? 1 : -1 + preparePredictedDayPage(direction: direction) + enqueueHorizontalCrownOffset(motion.offsetDelta) + } + + /// 把最新表冠位移写入目标值,并启动唯一的逐帧追踪任务。 + /// + /// 目标最多领先显示值一小段屏宽:实体表掉帧时直接丢弃超出的历史输入, + /// 恢复绘制后不会补播一串已经过时的页面动画。 + private func enqueueHorizontalCrownOffset(_ delta: CGFloat) { + let maximumLead = max( + dayHorizontalFrameMinimumLead, + horizontalPageWidth * dayHorizontalFrameMaximumLeadRatio + ) + let requestedTarget = horizontalTargetPageOffset + delta + horizontalTargetPageOffset = min( + horizontalPageOffset + maximumLead, + max(horizontalPageOffset - maximumLead, requestedTarget) + ) + startHorizontalFrameSmoothingIfNeeded() + } + + /// 使用接近表盘刷新上限的节拍追踪最新目标,而不是按表冠事件频率跳点。 + /// + /// 每一帧只消费“当前目标与当前显示值”的差,不保存位移队列;即使某帧 + /// 被系统跳过,下一帧也只朝最新位置前进,不追赶掉帧期间的旧动画。 + private func startHorizontalFrameSmoothingIfNeeded() { + guard horizontalFrameTask == nil else { return } + horizontalFrameToken += 1 + let token = horizontalFrameToken + horizontalFrameTask = Task { @MainActor in + while !Task.isCancelled, token == horizontalFrameToken { + try? await Task.sleep( + nanoseconds: dayHorizontalFrameIntervalNanoseconds + ) + guard !Task.isCancelled, + token == horizontalFrameToken, + !pageTransitionInFlight + else { + return + } + + let remaining = horizontalTargetPageOffset + - horizontalPageOffset + if abs(remaining) <= dayHorizontalFrameSettledDistance { + performWithoutAnimation { + horizontalPageOffset = horizontalTargetPageOffset + } + horizontalFrameTask = nil + return + } + + let frameDelta = remaining * dayHorizontalFrameFollowRatio + advanceHorizontalCrownFrame(by: frameDelta) + } + } + } + + /// 提交一帧显示位移;越过整屏时环形轮换三页并同步归一化目标值。 + private func advanceHorizontalCrownFrame(by delta: CGFloat) { + let update = normalizedContinuousPageOffset( + horizontalPageOffset + delta, + pageWidth: horizontalPageWidth + ) + + if update.crossedPage == 0 { + performWithoutAnimation { + horizontalPageOffset = update.offset + } + return + } + + performWithoutAnimation { + moveDay( + update.crossedPage, + preservesHorizontalNavigation: true + ) + horizontalPageOffset = update.offset + // 显示坐标归一化一屏时,目标坐标必须同步平移;两者差值保持 + // 不变,持续旋转便可毫无停顿地进入下一页。 + horizontalTargetPageOffset += CGFloat(update.crossedPage) + * horizontalPageWidth + } + crownPageRamp.recordCommittedPage() + onCrownPageInput() + } + + /// 停止逐帧追踪并丢弃尚未显示的目标;吸附从用户实际看到的位置开始。 + private func cancelHorizontalFrameSmoothing() { + horizontalFrameToken += 1 + horizontalFrameTask?.cancel() + horizontalFrameTask = nil + horizontalTargetPageOffset = horizontalPageOffset + } + + /// 系统报告空闲后再确认一小段无新刻度时间,才允许启动吸附。 + /// + /// `onIdle` 在慢速连续旋转的相邻刻度间也可能短暂触发;延后一小段时间 + /// 并允许下一个 `onChange` 取消任务,可确保动画入口只对应真正停止。 + private func handleDayCrownIdle() { + crownIdleCoordinator.scheduleIdleConfirmation { + settleDayCrownAfterInput() + } + } + + /// 把未完成的表冠位移吸附到前、当前或后一页;不依赖 Crown 焦点来源。 + private func settleDayCrownAfterInput() { + guard !pageTransitionInFlight else { return } + if continuousDayNavigation || abs(horizontalPageOffset) > 0.5 { + let direction = nearestPageDirection( + for: horizontalPageOffset, + width: horizontalPageWidth + ) + settleDayPage( + direction: direction, + velocity: horizontalCrownVelocity, + source: .crown + ) + } else { + // 纯纵向浏览没有吸附和回弹;表冠停在哪里,卡片就保持在哪里。 + horizontalCrownVelocity = 0 + crownSession.reset() + courseLayoutTracker.resumePersistence() + } + } + + /// 动画到目标页后才原子替换日期,再无动画复位三页容器的位置。 + private func settleDayPage( + direction: Int, + velocity: CGFloat, + source: DayPageTransitionSource = .direct + ) { + crownIdleCoordinator.cancel() + cancelHorizontalFrameSmoothing() + if direction != 0 { + preparePredictedDayPage(direction: direction) + } + let snap = horizontalPageSnap( + direction: direction, + currentOffset: horizontalPageOffset, + velocity: velocity, + width: horizontalPageWidth + ) + + pageTransitionToken += 1 + let token = pageTransitionToken + pageTransitionTask?.cancel() + pageTransitionInFlight = true + pageTransitionSource = source + pendingSnapDirection = snap.direction + prepareDayInputRecognitionForSnap(source: source) + withAnimation(calendarPageSnapAnimation(duration: snap.duration)) { + horizontalPageOffset = snap.target + } + + pageTransitionTask = makeCalendarPageCompletionTask( + after: snap.duration + ) { + guard token == pageTransitionToken else { return } + let landingDate = snap.direction == 0 + ? selectedDate + : dayDate(snap.direction) + let binding = dayCrownBinding( + for: store.courses(on: landingDate).count + ) + if snap.direction != 0 { + moveDay(snap.direction) + } + performWithoutAnimation { + horizontalPageOffset = 0 + horizontalTargetPageOffset = 0 + } + pageTransitionInFlight = false + pageTransitionSource = nil + pendingSnapDirection = 0 + horizontalCrownVelocity = 0 + courseLayoutTracker.resumePersistence() + rebindDayCrown(binding, transitionToken: token) + } + } + + /// 吸附开始时统一结束触摸识别,并按输入来源处理表冠会话。 + /// + /// 触摸和标题按钮发起的吸附会释放表冠焦点;表冠停止后发起的吸附则 + /// 保留焦点与横向会话,使过早到达的 `onIdle` 可被下一刻度立即中断。 + /// 两种路径都会停止纵向惯性,并重置分页器的触摸轴锁定。 + private func prepareDayInputRecognitionForSnap( + source: DayPageTransitionSource + ) { + cancelDayVerticalMomentum() + verticalTouchStartOffset = courseContentOffset + inputRecognitionResetToken += 1 + + guard source == .direct else { + // 表冠停止后的吸附仍保留焦点和横向会话。若用户再次旋转,新的 + // onChange 可以中断吸附;真正完成后再按落地页课程数重新绑定。 + crownFocused = true + return + } + + crownFocused = false + crownSession.reset() + crownPageRamp.reset() + lastCrownEventOffset = crownValue + horizontalCrownVelocity = 0 + continuousDayNavigation = false + } + + /// 新表冠刻度到达时中断由表冠自己发起、尚未完成的吸附。 + /// + /// SwiftUI 在 `withAnimation` 开始时模型值已经等于目标页。这里取消完成 + /// Task,并在无动画事务中提交该目标日期、归零三页容器,然后继续处理 + /// 当前刻度。这样不会让旧吸附动画继续阻塞最多 220ms,也不会累积掉帧 + /// 期间的历史位移。触摸或标题按钮发起的过渡不走这条可打断路径。 + private func resumeDayCrownFromPendingSnapIfNeeded() -> Bool { + guard pageTransitionInFlight else { return true } + guard pageTransitionSource == .crown else { return false } + + pageTransitionToken += 1 + pageTransitionTask?.cancel() + pageTransitionTask = nil + let direction = pendingSnapDirection + performWithoutAnimation { + if direction != 0 { + moveDay( + direction, + preservesHorizontalNavigation: true + ) + } + horizontalPageOffset = 0 + horizontalTargetPageOffset = 0 + pageTransitionInFlight = false + pageTransitionSource = nil + pendingSnapDirection = 0 + continuousDayNavigation = true + } + crownFocused = true + return true + } + + /// 目标日期零或一项日程时继续横向翻页;两项及以上时恢复纵向浏览。 + private func dayCrownBinding(for courseCount: Int) -> DayCrownBinding { + courseCount <= 1 ? .horizontalPages : .verticalCourses + } + + /// 在吸附完成后更新导航轴,并于下一次主线程循环重新取得表冠焦点。 + /// + /// 延后一帧可确保新日期的中间页和透明 Crown 观察器已经挂载。Token + /// 校验会拒绝上一轮动画迟到的聚焦请求。 + private func rebindDayCrown( + _ binding: DayCrownBinding, + transitionToken: Int + ) { + continuousDayNavigation = binding == .horizontalPages + crownSession.reset() + lastCrownEventOffset = crownValue + + DispatchQueue.main.async { + guard transitionToken == pageTransitionToken, + !pageTransitionInFlight, + !isDatePickerPresented + else { + return + } + crownFocused = true + } + } + +} + +/// 日分页器中一张按真实日期复用的页面。 +/// +/// 页面显式实现 `Equatable`,横向偏移的每帧变化不会重新计算未变化的卡片、 +/// 时间格式和滚动高度。一天通常只有少量日程,因此这里使用完整 `VStack` +/// 预渲染三页;相比 `LazyVStack`,它能避免相邻页刚进入屏幕时才补画卡片。 +private struct DaySchedulePageContent: View, Equatable { + let date: Date + let courses: [WatchCourse] + let viewportSize: CGSize + let courseOffset: CGFloat + let languageIdentifier: String + let onCourseLayoutMetricsChange: (DayCourseLayoutMetrics) -> Void + + static func == ( + lhs: DaySchedulePageContent, + rhs: DaySchedulePageContent + ) -> Bool { + lhs.date == rhs.date + && lhs.courses == rhs.courses + && lhs.viewportSize == rhs.viewportSize + && lhs.courseOffset == rhs.courseOffset + && lhs.languageIdentifier == rhs.languageIdentifier + } + + @ViewBuilder + var body: some View { + if courses.isEmpty { + emptyDayState + } else { + InteractionAwareScrollView( + onScroll: {}, + protectsInitialTopEdge: true, + alwaysProtectsInitialTopEdge: true, + protectedTopInsetRatio: 0.25, + topScrollTarget: AnyHashable( + DayScrollTopTarget(date: date) + ) + ) { + VStack(spacing: 5) { + ForEach(courses) { course in + CourseRow( + course: course, + showsInlineMetadata: true + ) + .id(course.id) + .background { + GeometryReader { cardProxy in + Color.clear.preference( + key: DayCourseLayoutPreferenceKey.self, + value: DayCourseLayoutMetrics( + cardHeights: [ + course.id: cardProxy.size.height, + ] + ) + ) + } + } + } + } + .padding(.horizontal, 2) + .padding(.top, 1) + // 表冠浏览只更新合成位移,避免逐帧执行 scrollTo 触发布局定位。 + .offset(y: courseOffset) + } + // 纵向触摸与表冠统一由外层分页手势修改 `courseOffset`。 + // ScrollView 只负责安全区、测量与裁剪,不维护独立滚动锚点。 + .scrollDisabled(true) + .onPreferenceChange(DayCourseLayoutPreferenceKey.self) { metrics in + onCourseLayoutMetricsChange(metrics) + } + } + } + + /// 无课程时保持既有居中布局。 + private var emptyDayState: some View { + let contentHeight: CGFloat = 60 + + return VStack(spacing: 8) { + Image(systemName: "cup.and.saucer") + .font(.title2) + .foregroundStyle(.secondary) + Text("当天没有课程") + .font(.headline) + } + .frame(width: viewportSize.width, height: contentHeight) + .position( + x: viewportSize.width / 2, + y: viewportSize.height / 2 + ) + } +} + +/// 汇总当前日所有卡片的固定高度。 +/// +/// 数值不随表冠位移改变,因此布局完成后不会持续触发偏好链。 +private struct DayCourseLayoutPreferenceKey: PreferenceKey { + static var defaultValue = DayCourseLayoutMetrics() + + static func reduce( + value: inout DayCourseLayoutMetrics, + nextValue: () -> DayCourseLayoutMetrics + ) { + let next = nextValue() + value.cardHeights.merge(next.cardHeights) { _, new in new } + } +} + +/// 日视图横向表冠运动的逐帧参数。 +/// +/// 16.67ms 对齐活动界面的 60Hz 帧预算,避免在同一显示帧内提交两次无效 +/// 状态而挤占布局时间;系统降低刷新率时仍会自然合并。每帧逼近最新目标 +/// 58%,既消除 detent 跳点,又能在约五帧内收敛且不形成长尾追赶。 +private let dayHorizontalFrameIntervalNanoseconds: UInt64 = 16_666_667 +private let dayHorizontalFrameFollowRatio: CGFloat = 0.58 +private let dayHorizontalFrameMaximumLeadRatio: CGFloat = 0.62 +private let dayHorizontalFrameMinimumLead: CGFloat = 24 +private let dayHorizontalFrameSettledDistance: CGFloat = 0.35 + +/// 日视图纵向触摸的惯性参数。 +/// +/// 速度单位统一为 point/second。指数摩擦不依赖具体刷新率,因此实体表在 +/// 30Hz 或 60Hz 下具有接近的滑行距离;单帧时间上限负责丢弃卡顿期间积压 +/// 的位移,避免恢复绘制后卡片突然跳动。 +private let dayVerticalMomentumMinimumVelocity: CGFloat = 55 +private let dayVerticalMomentumStopVelocity: CGFloat = 22 +private let dayVerticalMomentumMaximumVelocity: CGFloat = 1_600 +private let dayVerticalMomentumFriction = 7.2 +private let dayVerticalMomentumMaximumFrameDuration = 1.0 / 30.0 +private let dayVerticalMomentumSpringStiffness: CGFloat = 210 +private let dayVerticalMomentumSpringDamping: CGFloat = 23 + +/// 返回当前越界位置需要吸附的边界;正常区间内返回 `nil`。 +private func dayVerticalMomentumBoundary( + for offset: CGFloat, + restingRange: ClosedRange +) -> CGFloat? { + if offset > restingRange.upperBound { + return restingRange.upperBound + } + if offset < restingRange.lowerBound { + return restingRange.lowerBound + } + return nil +} + +/// 根据系统预测终点估算纵向松手末速度。 +/// +/// `predictedEndTranslation` 表示系统按当前手势趋势预计的减速终点。减去 +/// 实际位移后再除以约 0.2 秒预测窗口,可得到带方向的释放速度;最后限制 +/// 极端甩动,防止很短的一次触摸跨过整张表盘。 +private func verticalDragReleaseVelocity( + _ value: DragGesture.Value +) -> CGFloat { + let projectedRemainder = value.predictedEndTranslation.height + - value.translation.height + let estimatedVelocity = projectedRemainder / 0.2 + return min( + dayVerticalMomentumMaximumVelocity, + max(-dayVerticalMomentumMaximumVelocity, estimatedVelocity) + ) +} diff --git a/watchOS/Views/InteractionAwareScrollView.swift b/watchOS/Views/InteractionAwareScrollView.swift new file mode 100644 index 00000000..c3a697f5 --- /dev/null +++ b/watchOS/Views/InteractionAwareScrollView.swift @@ -0,0 +1,579 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import SwiftUI + +/// 新手教学中由手指直接驱动内容的视觉方式。 +/// +/// 它只补足“教学已经识别拖动、原生 ScrollView 却没有建立滚动会话”的 +/// 情况,不参与教学结果判断,也不会把触摸伪装成表冠输入。 +enum TeachingTouchScrollEffect { + case disabled + /// 短内容没有真实滚动范围时,显示有限的橡皮筋位移并在抬手后归位。 + case elastic + /// 长列表把触摸位移映射为原生 ScrollView 的绝对滚动位置。 + case nativePosition + + var isEnabled: Bool { + self != .disabled + } +} + +/// 能检测触摸/表冠导致的滚动,并通知根页面隐藏悬浮按钮。 +struct InteractionAwareScrollView: View { + let onScroll: () -> Void + /// 原生滚动阶段确认输入来自 Digital Crown 时单独通知。 + var onCrownInput: () -> Void = {} + /// 原生滚动阶段确认手指已经带动内容时单独通知。 + var onTouchInput: () -> Void = {} + var centersShortContent = false + /// `LazyVStack` 必须保留 ScrollView 提供的原生尺寸提案。若对懒加载 + /// 内容使用短内容所需的 `.fixedSize` 测量时,系统可能只建立一屏滚动 + /// 范围;懒加载路径保留原生尺寸提案以建立完整滚动范围。 + var usesLazyContentLayout = false + /// 教学短内容也允许使用系统橡皮筋随手指移动;正常页面仍按内容尺寸 + /// 决定是否滚动,不改变既有布局和滚动范围。 + var alwaysAllowsBounce = false + /// 需要可靠区分触摸与表冠、且实体表可能不发送 `.tracking` 时挂载兜底。 + /// + /// 默认只记录输入来源;调用方启用教学视觉代理时,同一份手势数据也用于推动内容。 + var usesShortContentTouchFallback = false + /// 教学步骤切换时使旧输入及延迟回调失效,不重建或重定位实际列表。 + var inputContext: Int = 0 + /// 教学专用视觉滚动。正常使用与其他教学步骤始终保持 `.disabled`。 + var teachingTouchScrollEffect: TeachingTouchScrollEffect = .disabled + /// 顶层详情覆盖周视图时,由内部原生 ScrollView 主动接管表冠焦点。 + var requestsCrownFocus = false + var protectsInitialTopEdge = false + var alwaysProtectsInitialTopEdge = false + var protectedTopInsetRatio: CGFloat = 0.13 + var topScrollTarget: AnyHashable = AnyHashable( + "interaction-aware-scroll-top" + ) + @ViewBuilder let content: () -> Content + @State private var offsetTracker = ScrollOffsetTracker() + @State private var intrinsicContentHeight: CGFloat = 0 + @State private var nativeScrollSawTouchTracking = false + @State private var nativeScrollReportedInput = false + /// 部分实体表在短内容橡皮筋滚动时会跳过 `.tracking`,直接进入 + /// `.interacting`。这两个状态只补记“手指正在接触”,不接管滚动。 + @State private var nativeTouchGestureIsActive = false + @State private var touchCompletionGate = WatchInputCompletionGate() + /// 记录当前手指是否已经形成明确的纵向拖动。短内容只发生橡皮筋 + /// 位移时,watchOS 偶尔不会建立完整 ScrollPhase,会由它在抬手后 + /// 兜底提交一次真实触摸滚动。 + @State private var nativeTouchGestureMovedVertically = false + /// 同一次拖动可能同时经过 DragGesture 兜底和系统 `.idle`。使用代次 + /// 去重,确保教学只收到一次完成事件,不会自动跨过相邻步骤。 + @GestureState private var touchMarkerIsRecognized = false + @State private var touchCompletionTask: Task? + @State private var touchResetTask: Task? + @State private var legacyCrownCompletionTask: Task? + /// 教学触摸的起点与显示状态独立于输入来源判断,避免视觉位移反过来影响判定。 + @State private var teachingDragStartScrollOffset: CGFloat = 0 + @State private var teachingRequestedScrollOffset: CGFloat = 0 + @State private var teachingElasticOffset: CGFloat = 0 + @FocusState private var nativeScrollFocused: Bool + + var body: some View { + GeometryReader { viewport in + let protectedTopInset = max( + 26, + viewport.size.height * protectedTopInsetRatio + ) + let contentOverflows = usesLazyContentLayout + || intrinsicContentHeight + + (protectsInitialTopEdge ? protectedTopInset : 0) + > viewport.size.height + let shouldProtectTopEdge = protectsInitialTopEdge + && (alwaysProtectsInitialTopEdge || contentOverflows) + let initialTopInset = shouldProtectTopEdge + ? protectedTopInset + : 0 + + let nativeScrollView = ScrollView { + GeometryReader { proxy in + Color.clear.preference( + key: ScrollOffsetPreferenceKey.self, + value: proxy.frame( + in: .named("watchScheduleScroll") + ).minY + ) + } + .frame(height: 0) + .id(topScrollTarget) + + scrollContent( + viewportHeight: viewport.size.height, + initialTopInset: initialTopInset, + contentOverflows: contentOverflows + ) + } + .coordinateSpace(name: "watchScheduleScroll") + .scrollIndicators(.hidden) + .scrollBounceBehavior( + alwaysAllowsBounce ? .always : .basedOnSize, + axes: .vertical + ) + // 教学视觉代理启用时由同一份 DragGesture 数据唯一驱动位置, + // 避免系统滚动与代理同时响应而产生双倍位移。程序化定位仍可用。 + .scrollDisabled(disablesNativeScrollForTeaching) + + crownFocusedScrollView( + touchObservedScrollView( + teachingPositionedScrollView(nativeScrollView) + ) + ) + .watchNativeCrownInputDetection( + sawTouchTracking: $nativeScrollSawTouchTracking, + reportedInput: $nativeScrollReportedInput, + onInteractionBegan: onScroll, + onCrownInput: onCrownInput, + onTouchInput: reportNativeTouchCompletion + ) + .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in + guard let previousOffset = offsetTracker.previousOffset else { + offsetTracker.previousOffset = offset + return + } + offsetTracker.previousOffset = offset + guard abs(offset - previousOffset) > 0.25 else { return } + onScroll() + observeLegacyCrownScroll() + } + .onPreferenceChange( + ScrollContentHeightPreferenceKey.self + ) { height in + guard abs(height - intrinsicContentHeight) > 0.5 else { + return + } + intrinsicContentHeight = height + } + .task(id: requestsCrownFocus) { + guard requestsCrownFocus else { + nativeScrollFocused = false + return + } + // 详情覆盖层与底层周视图会在同一事务内切换焦点。至少让出 + // 一次主线程更新,确保新的原生 ScrollView 已加入焦点树。 + nativeScrollFocused = false + await Task.yield() + guard !Task.isCancelled else { return } + nativeScrollFocused = true + } + .onDisappear { + nativeScrollFocused = false + resetInputObservation() + offsetTracker.previousOffset = nil + } + .onChange(of: inputContext) { _, _ in resetInputObservation() } + .onChange(of: teachingTouchScrollEffect) { _, _ in resetInputObservation() } + .onChange(of: usesShortContentTouchFallback) { _, _ in resetInputObservation() } + .onChange(of: touchMarkerIsRecognized) { _, isRecognized in + if !isRecognized, nativeTouchGestureIsActive { + // onEnded 不处理系统取消;取消只复位视觉和来源,不提交教学。 + resetInputObservation() + } + } + } + } + + /// 仅在当前系统确实具备对应视觉代理时禁用系统手势滚动。 + /// + /// `.elastic` 完全由本视图的偏移实现;`.nativePosition` 则依赖 + /// watchOS 11 的 `ScrollPosition`。watchOS 10 没有该 API,必须保留 + /// 原生滚动,否则会出现教学能识别手势但课程列表完全不移动的问题。 + private var disablesNativeScrollForTeaching: Bool { + switch teachingTouchScrollEffect { + case .disabled: + return false + case .elastic: + return true + case .nativePosition: + if #available(watchOS 11.0, *) { + return true + } + return false + } + } + + /// watchOS 11 起使用 `ScrollPosition` 连续推动原生滚动容器。 + /// + /// 可用性分支保证工程仍可部署到 watchOS 10;旧系统继续使用原生 + /// ScrollView,不会影响正常页面和已有表冠操作。 + @ViewBuilder + private func teachingPositionedScrollView( + _ scrollView: ScrollContent + ) -> some View { + if #available(watchOS 11.0, *), + teachingTouchScrollEffect == .nativePosition + { + TeachingNativeScrollPositionBridge( + requestedOffset: $teachingRequestedScrollOffset, + content: scrollView + ) + } else { + scrollView + } + } + + /// 仅给明确需要可靠来源判定的教学页面添加触摸来源兜底。 + /// + /// 条件分支发生在整个 ScrollView 外侧。手势使用 simultaneous 旁路; + /// 正常页面只写入来源标记,指定教学步骤会把同一份位移交给视觉代理。 + @ViewBuilder + private func touchObservedScrollView( + _ scrollView: ScrollContent + ) -> some View { + if usesShortContentTouchFallback { + scrollView.simultaneousGesture(nativeTouchSourceMarker) + } else { + scrollView + } + } + + /// 把焦点修饰器直接安装到原生 ScrollView,而不是详情页的外层容器。 + @ViewBuilder + private func crownFocusedScrollView( + _ scrollView: ScrollContent + ) -> some View { + if requestsCrownFocus { + scrollView + .focusable() + .focused($nativeScrollFocused) + } else { + scrollView + } + } + + /// 根据内容实现选择滚动布局。 + /// + /// 普通内容先测量自然高度,以便短内容垂直居中;懒加载内容完全交给 + /// ScrollView 建立滚动范围,只补最小视口高度和顶部安全距离。两条路径 + /// 使用相同的可见布局参数,不改变课程卡片本身的位置与样式。 + @ViewBuilder + private func scrollContent( + viewportHeight: CGFloat, + initialTopInset: CGFloat, + contentOverflows: Bool + ) -> some View { + if usesLazyContentLayout { + content() + .frame( + minHeight: max(0, viewportHeight - initialTopInset), + alignment: .top + ) + .padding(.top, initialTopInset) + } else { + content() + // 先测量内容的自然高度,再决定使用居中还是顶部布局。 + // 测量器放在最小高度 frame 之前,避免把视口高度误认为 + // 内容自身高度。 + .fixedSize(horizontal: false, vertical: true) + .background { + GeometryReader { contentProxy in + Color.clear.preference( + key: ScrollContentHeightPreferenceKey.self, + value: contentProxy.size.height + ) + } + } + .frame( + minHeight: max(0, viewportHeight - initialTopInset), + alignment: centersShortContent && !contentOverflows + ? .center + : .top + ) + // 长内容首次打开时从状态栏下方开始;这段 padding 位于 + // ScrollView 内,用户转动表冠或上滑后仍可进入顶部虚化区。 + .padding(.top, initialTopInset) + .offset( + y: teachingTouchScrollEffect == .elastic + ? teachingElasticOffset + : 0 + ) + } + } + + /// 给系统 ScrollView 补充触摸来源标记。 + /// + /// watchOS 在内容不足一屏、仅发生橡皮筋位移时,实体设备偶尔不会发出 + /// `.tracking`,但仍会发出与表冠相同的 `.interacting`。这里使用同时 + /// `DragGesture` 识别真实手指接触。正常滚动由系统 `.idle` 提交;若 + /// 触摸没有形成滚动会话,则在抬手后兜底提交并自动清除来源标记。 + private var nativeTouchSourceMarker: some Gesture { + DragGesture(minimumDistance: 2, coordinateSpace: .local) + .updating($touchMarkerIsRecognized) { _, active, _ in active = true } + .onChanged { value in + if !nativeTouchGestureIsActive { + cancelInputCompletionTasks() + nativeTouchGestureIsActive = true + touchCompletionGate.begin() + nativeTouchGestureMovedVertically = false + nativeScrollSawTouchTracking = true + onScroll() + beginTeachingTouchScroll() + } + + updateTeachingTouchScroll(using: value) + + if isVerticalTeachingDrag(value.translation) { + nativeTouchGestureMovedVertically = true + } + } + .onEnded { value in + nativeTouchGestureIsActive = false + finishTeachingTouchScroll() + let generation = touchCompletionGate.generation + let endedVertically = isVerticalTeachingDrag(value.translation) + let completedVerticalDrag = + nativeTouchGestureMovedVertically || endedVertically + + // 正常长内容由 ScrollPhase 在 `.idle` 统一提交;短内容若根本 + // 没有形成系统滚动会话,则在抬手后补交。等待一帧附近的短 + // 延迟,优先让系统阶段取得所有权,避免触摸/表冠来源竞争。 + if completedVerticalDrag { + touchCompletionTask?.cancel() + touchCompletionTask = makeWatchAutoDismissTask(after: 0.08) { + guard generation == touchCompletionGate.generation, + !touchCompletionGate.hasCompletedTouch, + !nativeScrollReportedInput + else { return } + reportNativeTouchCompletion() + touchCompletionTask = nil + } + } + + scheduleTouchSourceReset(generation: generation) + } + } + + /// 拖动中与松手时使用同一阈值,排除轻点抖动及横向占优的动作。 + private func isVerticalTeachingDrag(_ translation: CGSize) -> Bool { + let verticalDistance = abs(translation.height) + return verticalDistance >= 8 && verticalDistance >= abs(translation.width) + } + + /// watchOS 10 没有 ScrollPhase。仅在教学开启来源标记且没有程序化视觉 + /// 代理时,使用实际滚动位移与 0.35 秒静止窗口补报表冠。教学列表不执行 + /// 首次 scrollTo,手指及其惯性始终被来源标记排除;普通浏览不走推断路径。 + private func observeLegacyCrownScroll() { + if #available(watchOS 11.0, *) { return } + if nativeScrollSawTouchTracking { + // 旧系统没有减速阶段回调。惯性仍在移动时延后来源清理,不能把 + // 手指松开 0.35 秒以后的惯性尾段错认成下一轮表冠。 + if !nativeTouchGestureIsActive { + scheduleTouchSourceReset(generation: touchCompletionGate.generation) + } + return + } + guard usesShortContentTouchFallback, + !teachingTouchScrollEffect.isEnabled, + !nativeTouchGestureIsActive, + !nativeScrollSawTouchTracking + else { return } + let generation = touchCompletionGate.generation + legacyCrownCompletionTask?.cancel() + legacyCrownCompletionTask = makeWatchAutoDismissTask(after: 0.35) { + guard generation == touchCompletionGate.generation, + !nativeTouchGestureIsActive, !nativeScrollSawTouchTracking + else { return } + legacyCrownCompletionTask = nil + onCrownInput() + } + } + + private func scheduleTouchSourceReset(generation: Int) { + touchResetTask?.cancel() + touchResetTask = makeWatchAutoDismissTask(after: 0.35) { + guard generation == touchCompletionGate.generation, + !nativeScrollReportedInput else { return } + nativeScrollSawTouchTracking = false + touchResetTask = nil + } + } + + private func cancelInputCompletionTasks() { + touchCompletionTask?.cancel() + touchCompletionTask = nil + touchResetTask?.cancel() + touchResetTask = nil + legacyCrownCompletionTask?.cancel() + legacyCrownCompletionTask = nil + } + + /// 页面退出、步骤变化和系统取消共用一个失效入口。 + private func resetInputObservation() { + cancelInputCompletionTasks() + touchCompletionGate.begin() + nativeTouchGestureIsActive = false + nativeTouchGestureMovedVertically = false + nativeScrollSawTouchTracking = false + nativeScrollReportedInput = false + offsetTracker.previousOffset = nil + finishTeachingTouchScroll() + } + + /// 记录本轮拖动开始时的真实滚动位置。 + private func beginTeachingTouchScroll() { + guard teachingTouchScrollEffect.isEnabled else { return } + let currentOffset = max(0, -(offsetTracker.previousOffset ?? 0)) + teachingDragStartScrollOffset = currentOffset + teachingRequestedScrollOffset = currentOffset + } + + /// 使用手指总位移更新教学视觉位置,不改变已有的方向/阈值判定。 + private func updateTeachingTouchScroll( + using value: DragGesture.Value + ) { + switch teachingTouchScrollEffect { + case .disabled: + return + case .elastic: + // 短内容没有可滚动区间,使用递减增益形成系统风格皮筋效果。 + // 最大位移受限,避免教学层下方的布局被拖出表盘。 + let translation = value.translation.height + let magnitude = min(abs(translation), 80) + let resistedMagnitude = min(26, magnitude * 0.34) + teachingElasticOffset = translation < 0 + ? -resistedMagnitude + : resistedMagnitude + case .nativePosition: + guard #available(watchOS 11.0, *) else { return } + // 上滑(负 translation)对应增大内容偏移,下滑则减小。 + teachingRequestedScrollOffset = max( + 0, + teachingDragStartScrollOffset - value.translation.height + ) + } + } + + /// 短内容抬手后柔和归位;长列表停在手指实际拖到的位置。 + private func finishTeachingTouchScroll() { + guard teachingTouchScrollEffect == .elastic else { return } + withAnimation(.interactiveSpring(response: 0.3, dampingFraction: 0.8)) { + teachingElasticOffset = 0 + } + } + + /// 合并系统 ScrollPhase 与短内容拖动兜底的唯一完成入口。 + private func reportNativeTouchCompletion() { + let generation = touchCompletionGate.generation + // 安装了触摸标记的页面可能同时从 DragGesture 兜底与 ScrollPhase + // 收到完成事件,需要按代次去重。未安装标记的普通长列表没有手势 + // 代次(永远为 0),其每一轮原生 `.idle` 本身已经唯一,不能用同一 + // 个 0 去重,否则首轮异常事件会吃掉后续所有真实触摸。 + if usesShortContentTouchFallback { + guard touchCompletionGate.completeTouch(for: generation) else { + return + } + } + onTouchInput() + } +} + +/// 把教学层给出的绝对偏移写入 SwiftUI 原生 ScrollView。 +/// +/// 单独放进 watchOS 11 可用性类型中,避免 `ScrollPosition` 抬高整个 App +/// 的最低系统版本。每个拖动采样关闭隐式动画,使列表逐帧贴合手指,而不是 +/// 累积一串尚未完成的吸附动画。 +@available(watchOS 11.0, *) +private struct TeachingNativeScrollPositionBridge: View { + @Binding var requestedOffset: CGFloat + @State private var position = ScrollPosition(y: 0) + let content: Content + + var body: some View { + content + .scrollPosition($position) + .onChange(of: requestedOffset) { _, offset in + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + position.scrollTo(y: max(0, offset)) + } + } + } +} + +extension View { + /// watchOS 11 起用原生滚动阶段识别表冠;旧系统保持原页面。 + /// + /// 可用性分支收口在这个修饰器内,避免把整个滚动组件的 + /// 最低系统从 watchOS 10 提高到 watchOS 11。 + @ViewBuilder + func watchNativeCrownInputDetection( + sawTouchTracking: Binding, + reportedInput: Binding, + onInteractionBegan: @escaping () -> Void, + onCrownInput: @escaping () -> Void, + onTouchInput: @escaping () -> Void + ) -> some View { + if #available(watchOS 11.0, *) { + onScrollPhaseChange { oldPhase, newPhase in + if newPhase == .tracking { + sawTouchTracking.wrappedValue = true + if !reportedInput.wrappedValue { + reportedInput.wrappedValue = true + onInteractionBegan() + } + return + } + if newPhase == .interacting, + !reportedInput.wrappedValue + { + reportedInput.wrappedValue = true + // 表冠刚进入系统滚动阶段就通知根页面隐去教学说明; + // 完成判定仍留到 `.idle`,不会在持续旋转时提前播放结果。 + onInteractionBegan() + } + if newPhase == .idle { + // `.interacting` 只标记来源;等系统确认完全停止后才上报, + // 教学结果不会在手指仍按住或表冠仍旋转时遮住真实页面。 + if reportedInput.wrappedValue { + if sawTouchTracking.wrappedValue + || oldPhase == .tracking + { + onTouchInput() + } else { + onCrownInput() + } + } + sawTouchTracking.wrappedValue = false + reportedInput.wrappedValue = false + } + } + } else { + self + } + } +} + +/// 只记录滚动采样值,不参与 SwiftUI 依赖追踪。 +/// +/// 引用型记录器可比较相邻采样并触发控件隐藏,但不会把 +/// 每个像素的不可见偏移发布为 SwiftUI 状态,避免整个列表重新布局。 +private final class ScrollOffsetTracker { + var previousOffset: CGFloat? +} + +/// 在滚动内容与外层视图之间传递当前纵向偏移。 +private struct ScrollOffsetPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + +/// 记录滚动内容未施加视口最小高度前的自然高度。 +/// +/// 根页面据此判断内容能否在一页内完整展示:短内容垂直居中,长内容则保留 +/// 一段可滚走的状态栏安全距离。 +private struct ScrollContentHeightPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} diff --git a/watchOS/Views/MonthCalendarData.swift b/watchOS/Views/MonthCalendarData.swift new file mode 100644 index 00000000..630962fd --- /dev/null +++ b/watchOS/Views/MonthCalendarData.swift @@ -0,0 +1,261 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import Foundation + +/// 单个日期格的预计算数据;绘制阶段不再重复调用 `Calendar`。 +struct MonthCalendarCell: Equatable { + let date: Date + let text: String +} + +/// 单个月份预计算模型;最多保存 42 个轻量日期值,不创建按钮视图。 +struct MonthCalendarPageModel: Identifiable, Equatable { + let monthStart: Date + let cells: [MonthCalendarCell?] + let rowCount: Int + + var id: Date { monthStart } +} + +/// 某个有日程日期底部的五段节次标记。 +/// +/// 数组固定对应 `1–2、3–4、5–6、7–8、9–10` 节;`nil` 表示该段没有 +/// 日程,Canvas 会使用暗白色占位。没有任何日程的日期不会创建此模型。 +struct MonthPeriodMarker { + let segmentCourses: [WatchCourse?] +} + +/// 月份分页器的一组原子缓存。 +/// +/// 日期网格和课程标记必须来自同一个月份窗口。把两份字典包装成一个值, +/// 可以避免连续跨月和吸附完成时只替换其中一份,造成日期与色条短暂错位。 +struct MonthCalendarWindow { + let models: [Date: MonthCalendarPageModel] + let periodMarkers: [Date: [Int: MonthPeriodMarker]] +} + +/// 月视图专用的内存缓存。 +/// +/// Store 只需要提供持久化派生索引,月份日期模型、五段标记和三页窗口的生命 +/// 周期全部由该类型管理。日期模型不依赖课表,可以跨同步复用;五段标记引用 +/// 当前课表中的课程,因此课表索引替换时必须单独失效。 +struct MonthCalendarCache { + /// 八个最近访问的中心月足以复用相邻窗口,任意远期浏览也不会无限持有课程。 + static let maximumWindowCount = 8 + private var recentCenters: [Date] = [] + private var calendar = Calendar.current + private var models: [Date: MonthCalendarPageModel] = [:] + private var periodMarkers: [Date: [Int: MonthPeriodMarker]] = [:] + /// 以中心月为键保存已经组装好的三页原子窗口。 + /// + /// `models` 和 `periodMarkers` 负责跨窗口复用单月数据;这里再缓存最终 + /// 窗口,避免用户第一次打开月视图时才创建两份三元素字典。 + private var windows: [Date: MonthCalendarWindow] = [:] + + /// 目标月与相邻两页是否已经同时具备日期格和课表色段。 + /// + /// 新手引导进入课程列表前会查询这一状态。只有三页完整就绪才允许 + /// 黑色章节页淡出,避免第一次进入月视图时才在动画帧内组装网格。 + func isPrepared(around date: Date) -> Bool { + calendar == Calendar.current && windows[monthCalendarStart(for: date)] != nil + } + + /// 预生成目标月份前、中、后三页所需的全部轻量数据。 + mutating func prewarm( + around date: Date, + periodCourseIDsByDay: [Date: [String?]], + coursesByID: [String: WatchCourse] + ) { + resetForCalendarChangeIfNeeded() + let center = monthCalendarStart(for: date) + recordAccess(to: center) + guard windows[center] == nil else { return } + let starts = monthCalendarPageStarts(centeredOn: center) + for month in starts { + let model = model(for: month) + guard periodMarkers[month] == nil else { continue } + periodMarkers[month] = makePeriodMarkers( + for: model, + periodCourseIDsByDay: periodCourseIDsByDay, + coursesByID: coursesByID + ) + } + windows[center] = makeWindow(for: starts) + trimCachedWindows() + } + + /// 返回已成组准备好的三页窗口,确保日期和颜色标记来自同一批缓存。 + mutating func window( + centeredOn date: Date, + periodCourseIDsByDay: [Date: [String?]], + coursesByID: [String: WatchCourse] + ) -> MonthCalendarWindow { + let center = monthCalendarStart(for: date) + prewarm( + around: center, + periodCourseIDsByDay: periodCourseIDsByDay, + coursesByID: coursesByID + ) + if let cached = windows[center] { + return cached + } + // `prewarm` 正常路径必定写入窗口;保留无副作用兜底,防止未来调整 + // 缓存策略时让月份页面因缺少数据而无法构造。 + return makeWindow( + for: monthCalendarPageStarts(centeredOn: center) + ) + } + + /// 从已经准备好的单月缓存组装最终三页窗口。 + private func makeWindow(for starts: [Date]) -> MonthCalendarWindow { + return MonthCalendarWindow( + models: Dictionary( + uniqueKeysWithValues: starts.compactMap { month in + models[month].map { (month, $0) } + } + ), + periodMarkers: Dictionary( + uniqueKeysWithValues: starts.map { month in + (month, periodMarkers[month] ?? [:]) + } + ) + ) + } + + /// 课表发生变化时只清除课程相关标记,保留确定性的月份日期网格。 + mutating func invalidateScheduleMarkers() { + periodMarkers.removeAll(keepingCapacity: true) + windows.removeAll(keepingCapacity: true) + recentCenters.removeAll(keepingCapacity: true) + } + + private mutating func resetForCalendarChangeIfNeeded() { + guard calendar != Calendar.current else { return } + calendar = .current + models.removeAll(keepingCapacity: true) + invalidateScheduleMarkers() + } + + private mutating func recordAccess(to center: Date) { + recentCenters.removeAll { $0 == center } + recentCenters.append(center) + } + + /// 窗口淘汰后同步释放无人引用的网格和色段,三份缓存共用同一生命周期。 + private mutating func trimCachedWindows() { + while recentCenters.count > Self.maximumWindowCount { + windows.removeValue(forKey: recentCenters.removeFirst()) + } + let retainedMonths = Set(windows.values.flatMap { $0.models.keys }) + models = models.filter { retainedMonths.contains($0.key) } + periodMarkers = periodMarkers.filter { retainedMonths.contains($0.key) } + } + + /// 返回已有模型;缺失时只计算一次并存入缓存。 + private mutating func model(for month: Date) -> MonthCalendarPageModel { + let normalizedMonth = monthCalendarStart(for: month) + if let cached = models[normalizedMonth] { + return cached + } + let newModel = makeMonthCalendarPageModel(for: normalizedMonth) + models[normalizedMonth] = newModel + return newModel + } + + /// 把持久化课程 ID 索引转换为 Canvas 可直接读取的课程引用。 + private func makePeriodMarkers( + for model: MonthCalendarPageModel, + periodCourseIDsByDay: [Date: [String?]], + coursesByID: [String: WatchCourse] + ) -> [Int: MonthPeriodMarker] { + var markers: [Int: MonthPeriodMarker] = [:] + markers.reserveCapacity(model.cells.count) + let calendar = Calendar.current + + for (index, cell) in model.cells.enumerated() { + guard let cell else { continue } + let day = calendar.startOfDay(for: cell.date) + guard let courseIDs = periodCourseIDsByDay[day] else { continue } + let courses = courseIDs.map { courseID in + courseID.flatMap { coursesByID[$0] } + } + markers[index] = MonthPeriodMarker(segmentCourses: courses) + } + return markers + } +} + +/// 返回一个月窗口内的三个自然月起点。 +func monthCalendarPageStarts(centeredOn month: Date) -> [Date] { + let calendar = Calendar.current + let normalizedMonth = monthCalendarStart(for: month) + let previous = calendar.date( + byAdding: .month, + value: -1, + to: normalizedMonth + ) ?? normalizedMonth + let next = calendar.date( + byAdding: .month, + value: 1, + to: normalizedMonth + ) ?? normalizedMonth + return [previous, normalizedMonth, next].map { + monthCalendarStart(for: $0) + } +} + +/// 将一个自然月预计算成周一开头的 5 或 6 行日期模型。 +func makeMonthCalendarPageModel( + for month: Date +) -> MonthCalendarPageModel { + let calendar = Calendar.current + let monthStart = monthCalendarStart(for: month) + guard let dayRange = calendar.range(of: .day, in: .month, for: monthStart) + else { + return MonthCalendarPageModel( + monthStart: monthStart, + cells: [], + rowCount: 5 + ) + } + + // Apple weekday: 周日为 1;转换成“周一为第 0 列”的偏移。 + let leadingEmptyCount = (calendar.component(.weekday, from: monthStart) + 5) % 7 + let usedCellCount = leadingEmptyCount + dayRange.count + let rowCount = min(6, max(5, Int(ceil(Double(usedCellCount) / 7)))) + let totalCellCount = rowCount * 7 + + var cells = Array( + repeating: nil, + count: totalCellCount + ) + for dayOffset in 0.. Date { + let calendar = Calendar.current + let components = calendar.dateComponents( + [.year, .month], + from: date + ) + return calendar.date(from: components) ?? date +} diff --git a/watchOS/Views/MonthScheduleView.swift b/watchOS/Views/MonthScheduleView.swift new file mode 100644 index 00000000..b8c91406 --- /dev/null +++ b/watchOS/Views/MonthScheduleView.swift @@ -0,0 +1,799 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import SwiftUI + +/// 独立的日期选择页面。 +/// +/// 页面负责滚动月份、绘制轻量日程标记、命中日期以及提交或取消。它只从 +/// Store 的自然日索引预计算标记,不修改课表或日视图滚动状态。月份横向 +/// 分页复用日/周视图的三页容器;单月如有第六行仍可用手指纵向滚动, +/// 表冠始终用于横向翻月。 +struct MonthScheduleView: View { + @EnvironmentObject private var store: WatchScheduleStore + let initialDate: Date + let submit: (Date) -> Void + let cancel: () -> Void + let onEmptyTap: () -> Void + let onCrownInput: () -> Void + let onTouchInputBegan: () -> Void + let onSwipeInput: (CalendarPagingDragAxis) -> Void + let onHeaderPreviousTap: () -> Void + let onHeaderNextTap: () -> Void + /// 启动预热实例只负责建立真实渲染树,不取得表冠焦点或监听课表变化。 + let prewarmingOnly: Bool + + @State private var visibleMonth: Date + /// App 启动阶段已经准备好的当前月及相邻两月。 + @State private var loadedMonths: [Date: MonthCalendarPageModel] + /// 与月份模型同窗口缓存的节次标记;表冠逐帧移动时只做字典读取。 + @State private var periodMarkersByMonth: [ + Date: [Int: MonthPeriodMarker] + ] + @State private var horizontalPageOffset: CGFloat = 0 + @State private var horizontalTouchStartOffset: CGFloat = 0 + @State private var horizontalPageWidth: CGFloat = 1 + @State private var interactionResetToken = 0 + @State private var crownValue = 0.0 + @State private var lastCrownEventOffset = 0.0 + @State private var crownSession = WatchCrownTurnSession() + @State private var crownPageRamp = CalendarCrownPageRamp() + @State private var horizontalCrownVelocity: CGFloat = 0 + @State private var crownIdleCoordinator = CalendarCrownIdleCoordinator() + @State private var pageTransitionToken = 0 + @State private var pageTransitionInFlight = false + @State private var pageTransitionTask: Task? + @FocusState private var crownFocused: Bool + + init( + initialDate: Date, + initialWindow: MonthCalendarWindow, + submit: @escaping (Date) -> Void, + cancel: @escaping () -> Void, + onEmptyTap: @escaping () -> Void, + onCrownInput: @escaping () -> Void, + onTouchInputBegan: @escaping () -> Void, + onSwipeInput: @escaping (CalendarPagingDragAxis) -> Void, + onHeaderPreviousTap: @escaping () -> Void, + onHeaderNextTap: @escaping () -> Void, + prewarmingOnly: Bool = false + ) { + let normalizedDate = Calendar.current.startOfDay(for: initialDate) + let month = monthCalendarStart(for: normalizedDate) + self.initialDate = normalizedDate + self.submit = submit + self.cancel = cancel + self.onEmptyTap = onEmptyTap + self.onCrownInput = onCrownInput + self.onTouchInputBegan = onTouchInputBegan + self.onSwipeInput = onSwipeInput + self.onHeaderPreviousTap = onHeaderPreviousTap + self.onHeaderNextTap = onHeaderNextTap + self.prewarmingOnly = prewarmingOnly + _visibleMonth = State(initialValue: month) + _loadedMonths = State(initialValue: initialWindow.models) + _periodMarkersByMonth = State( + initialValue: initialWindow.periodMarkers + ) + } + + var body: some View { + // 使用独立导航容器持有月份标题栏。系统会把 `.toolbar` 提升到 + // 最近的 NavigationStack;若继续复用根导航容器,标题栏会脱离 + // 日期选择页的 move 转场。标题、星期栏和网格必须属于同一棵 + // 可转场视图,进入与退出时会作为整页同步移动。 + NavigationStack { + ZStack(alignment: .bottomLeading) { + VStack(spacing: 2) { + // 星期栏位于系统顶部栏下方,不参与月份滚动。 + MonthWeekdayHeader() + + CalendarHorizontalPager( + pageOffset: horizontalPageOffset, + interactionResetToken: interactionResetToken, + pageIdentity: monthDate, + page: monthPage, + onViewportWidthChange: { + horizontalPageWidth = max(1, $0) + }, + onViewportHeightChange: { _ in }, + onHorizontalDragBegan: beginHorizontalMonthDrag, + onHorizontalDragChanged: updateHorizontalMonthDrag, + onHorizontalDragEnded: finishHorizontalMonthDrag, + // 六行月份内部的上下拖动继续交给系统 ScrollView;分页器 + // 只负责识别并锁定横向手势,不实现第二套纵向物理。 + onVerticalDragBegan: {}, + onVerticalDragChanged: { _ in }, + onVerticalDragEnded: { _ in + onSwipeInput(.vertical) + }, + onDragAxisLocked: { _ in onTouchInputBegan() }, + onDragCancelled: { axis in + guard !pageTransitionInFlight else { return } + if axis == .horizontal { settleMonthPage(direction: 0, velocity: 0) } + } + ) + } + // 星期栏和网格作为一个整体靠近系统月份标题;只改变视觉位置, + // 不改变 ScrollView 高度、日期命中坐标或分页手势的计算基准。 + .offset(y: -3) + + monthCrownObserver + } + .background(Color.black.ignoresSafeArea()) + .onAppear { + guard !prewarmingOnly else { return } + crownFocused = true + lastCrownEventOffset = crownValue + } + // Store 安装新阶段或恢复持久化派生索引后递增修订号。月份页面只 + // 按索引刷新当前三页颜色,无需重新扫描全部课程。 + .onChange(of: store.renderCacheRevision) { _, _ in + guard !prewarmingOnly else { return } + replaceMonthWindow( + store.preparedMonthCalendarWindow( + centeredOn: visibleMonth + ) + ) + } + .task { + guard !prewarmingOnly else { return } + await activateMonthCrownAfterEntrance() + } + .onDisappear { + guard !prewarmingOnly else { return } + crownFocused = false + crownIdleCoordinator.cancel() + pageTransitionTask?.cancel() + } + .toolbar { + ToolbarItem(placement: .topBarLeading) { + DateNavigationHeader( + title: visibleMonth.formatted( + .dateTime + .month(.wide) + .locale(WatchWidgetShared.preferredLocale) + ), + previous: { + onHeaderPreviousTap() + requestMonthPage(-1) + }, + next: { + onHeaderNextTap() + requestMonthPage(1) + }, + titleAction: cancel + ) + .frame(width: 116) + .offset(y: -10) + } + } + } + } + + /// 返回横向三页容器中相对页对应的自然月。 + private func monthDate(_ relativePage: Int) -> Date { + guard let date = Calendar.current.date( + byAdding: .month, + value: relativePage, + to: visibleMonth + ) else { + return visibleMonth + } + return monthCalendarStart(for: date) + } + + /// 只绘制当前三页预热窗口中的月份;缺失页显示纯黑占位。 + @ViewBuilder + private func monthPage(_ relativePage: Int) -> some View { + let month = monthDate(relativePage) + if let model = loadedMonths[month] { + GeometryReader { viewport in + // 同一时刻最多显示五行。六行月份可在当前页内使用系统 + // ScrollView 以手指查看最后一行;表冠留给横向月份分页。 + let rowHeight = max(18, viewport.size.height / 5) + + ScrollView(.vertical) { + MonthCalendarCanvas( + model: model, + selectedDate: initialDate, + periodMarkers: periodMarkersByMonth[month] ?? [:], + rowHeight: rowHeight, + select: submit, + onEmptyTap: onEmptyTap + ) + .frame( + height: rowHeight * CGFloat(model.rowCount) + ) + } + .scrollIndicators(.hidden) + .scrollDisabled(relativePage != 0) + // 日期选择页的表冠始终用于横向翻月;这里关闭 ScrollView + // 的焦点资格,但不影响手指纵向查看六行月份的最后一行。 + .focusable(false) + } + } else { + Color.black + } + } + + private func beginHorizontalMonthDrag() { + guard !pageTransitionInFlight else { return } + crownIdleCoordinator.cancel() + crownFocused = true + horizontalTouchStartOffset = horizontalPageOffset + } + + /// 手指锁定为横向后确认目标页已经安装;正常情况只是缓存命中。 + private func updateHorizontalMonthDrag(_ translation: CGFloat) { + guard !pageTransitionInFlight else { return } + if abs(translation) > 1 { + installPreparedMonthIfNeeded( + relativePage: translation < 0 ? 1 : -1 + ) + } + performWithoutAnimation { + horizontalPageOffset = min( + horizontalPageWidth, + max( + -horizontalPageWidth, + horizontalTouchStartOffset + translation + ) + ) + } + } + + private func finishHorizontalMonthDrag(_ value: DragGesture.Value) { + guard !pageTransitionInFlight else { return } + onSwipeInput(.horizontal) + let motion = horizontalDragMotion( + value, + currentOffset: horizontalPageOffset, + pageWidth: horizontalPageWidth + ) + settleMonthPage( + direction: motion.direction, + velocity: motion.velocity + ) + } + + /// 标题栏箭头和手指分页共用相同的加载、位移与吸附路径。 + private func requestMonthPage(_ amount: Int) { + guard !pageTransitionInFlight else { return } + crownIdleCoordinator.cancel() + crownFocused = true + installPreparedMonthIfNeeded(relativePage: amount) + settleMonthPage( + direction: amount, + velocity: horizontalPageWidth * 2.2 + ) + } + + /// 处理跨页后窗口尚未换底或同步刚更新时的兜底安装。 + private func installPreparedMonthIfNeeded(relativePage: Int) { + let normalizedPage = min(1, max(-1, relativePage)) + let month = monthDate(normalizedPage) + guard loadedMonths[month] == nil else { return } + let prepared = store.preparedMonthCalendarWindow( + centeredOn: month + ) + guard let model = prepared.models[month] else { return } + performWithoutAnimation { + loadedMonths[month] = model + periodMarkersByMonth[month] = prepared.periodMarkers[month] ?? [:] + } + } + + /// 入场转场完成后重新取得表冠焦点。 + /// + /// 日期选择器通过根视图的条件分支和底部转场出现。真机上在 `onAppear` + /// 立即设置焦点时,焦点节点尚未完成挂载;随后底层日视图释放焦点以及 + /// 相邻月份缓存更新,都可能使首次请求失效。等待 320ms, + /// 再先释放、后绑定一次,确保后续刻度进入本页的横向分页处理器。 + @MainActor + private func activateMonthCrownAfterEntrance() async { + try? await Task.sleep(nanoseconds: 320_000_000) + guard !Task.isCancelled else { return } + crownFocused = false + await Task.yield() + guard !Task.isCancelled else { return } + crownSession.reset() + lastCrownEventOffset = crownValue + crownFocused = true + } + + /// 透明焦点节点独占日期选择页的表冠输入,不参与布局或触摸命中。 + /// + /// 参数与周视图保持一致,因此两个页面的机械刻度和连续翻页手感相同。 + private var monthCrownObserver: some View { + Color.clear + .frame(width: 1, height: 1) + .calendarPagingCrownInput( + detent: $crownValue, + focused: $crownFocused, + onChange: handleMonthCrownChange, + onIdle: handleMonthCrownIdle + ) + .accessibilityHidden(true) + } + + /// 从第一个有效表冠刻度起就直接横向移动月份,不经过纵向滚动或阈值路由。 + private func handleMonthCrownChange(_ event: DigitalCrownEvent) { + guard !pageTransitionInFlight, event.offset.isFinite, event.velocity.isFinite else { return } + let delta = frameBoundCrownDelta( + from: lastCrownEventOffset, + to: event.offset + ) + lastCrownEventOffset = event.offset + guard let update = crownSession.register(delta: delta) else { return } + crownIdleCoordinator.cancel() + onCrownInput() + crownPageRamp.register(update) + + applyMonthCrownDelta(delta, velocity: event.velocity) + crownIdleCoordinator.scheduleFallback { + settleMonthCrownAfterInput() + } + } + + /// 使用周视图相同的像素换算,把表冠刻度连续映射到三页横向容器。 + private func applyMonthCrownDelta(_ delta: Double, velocity: Double) { + let motion = calendarCrownPageMotion( + delta: delta, + velocity: velocity, + pageWidth: horizontalPageWidth, + distanceScale: crownPageRamp.distanceScale + ) + horizontalCrownVelocity = motion.velocity + if updateContinuousMonthOffset(by: motion.offsetDelta) != 0 { + crownPageRamp.recordCommittedPage() + } + } + + /// 越过整屏时立即提交月份并归一化位移,使一次持续旋转可以连续翻月。 + @discardableResult + private func updateContinuousMonthOffset(by delta: CGFloat) -> Int { + guard horizontalPageWidth > 0 else { return 0 } + let update = normalizedContinuousPageOffset( + horizontalPageOffset + delta, + pageWidth: horizontalPageWidth + ) + + guard update.crossedPage != 0 else { + performWithoutAnimation { + horizontalPageOffset = update.offset + } + return 0 + } + + let landingMonth = monthDate(update.crossedPage) + let nextWindow = preparedMonthWindow(centeredOn: landingMonth) + commitMonthWindow( + nextWindow, + visibleMonth: landingMonth, + pageOffset: update.offset + ) + WatchHaptics.navigation(update.crossedPage) + return update.crossedPage + } + + /// 系统报告空闲后使用与周视图相同的短确认窗,再吸附到最近月份。 + private func handleMonthCrownIdle() { + crownIdleCoordinator.scheduleIdleConfirmation { + settleMonthCrownAfterInput() + } + } + + private func settleMonthCrownAfterInput() { + guard !pageTransitionInFlight else { return } + let direction = nearestPageDirection( + for: horizontalPageOffset, + width: horizontalPageWidth + ) + settleMonthPage( + direction: direction, + velocity: horizontalCrownVelocity + ) + } + + /// 从 Store 的预热缓存读取目标月三页窗口。 + private func preparedMonthWindow( + centeredOn landingMonth: Date + ) -> MonthCalendarWindow { + store.preparedMonthCalendarWindow( + centeredOn: landingMonth + ) + } + + /// 仅替换三页缓存窗口,不改变当前月份和交互位移。 + private func replaceMonthWindow(_ window: MonthCalendarWindow) { + loadedMonths = window.models + periodMarkersByMonth = window.periodMarkers + } + + /// 连续跨页或吸附完成后,用一次无动画事务提交月份和配套缓存。 + private func commitMonthWindow( + _ window: MonthCalendarWindow, + visibleMonth: Date, + pageOffset: CGFloat + ) { + performWithoutAnimation { + self.visibleMonth = visibleMonth + horizontalPageOffset = pageOffset + horizontalTouchStartOffset = pageOffset + replaceMonthWindow(window) + } + } + + /// 完整翻页后原子切换月份,并回收为以新月份为中心的三页窗口。 + private func settleMonthPage(direction: Int, velocity: CGFloat) { + crownIdleCoordinator.cancel() + let normalizedDirection = min(1, max(-1, direction)) + if normalizedDirection != 0 { + installPreparedMonthIfNeeded(relativePage: normalizedDirection) + } + + let snap = horizontalPageSnap( + direction: normalizedDirection, + currentOffset: horizontalPageOffset, + velocity: velocity, + width: horizontalPageWidth + ) + pageTransitionToken += 1 + let token = pageTransitionToken + pageTransitionTask?.cancel() + pageTransitionInFlight = true + interactionResetToken += 1 + + withAnimation(calendarPageSnapAnimation(duration: snap.duration)) { + horizontalPageOffset = snap.target + } + + pageTransitionTask = makeCalendarPageCompletionTask( + after: snap.duration + ) { + guard token == pageTransitionToken else { return } + let landingMonth = normalizedDirection == 0 + ? visibleMonth + : monthDate(normalizedDirection) + let nextWindow = preparedMonthWindow(centeredOn: landingMonth) + commitMonthWindow( + nextWindow, + visibleMonth: landingMonth, + pageOffset: 0 + ) + pageTransitionInFlight = false + horizontalCrownVelocity = 0 + crownSession.reset() + if normalizedDirection != 0 { + WatchHaptics.navigation(normalizedDirection) + } + } + } +} + +/// 固定在月份网格上方的周一至周日标题。 +private struct MonthWeekdayHeader: View { + private let columns = Array( + repeating: GridItem(.flexible(), spacing: 0), + count: 7 + ) + + var body: some View { + LazyVGrid(columns: columns, spacing: 0) { + ForEach( + Array(mondayFirstWeekdaySymbols().enumerated()), + id: \.offset + ) { item in + Text(item.element) + .font(.system(size: 8, weight: .semibold)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.65) + .frame(maxWidth: .infinity) + } + } + .frame(height: 15) + // 与下方 Canvas 使用同一个完整宽度,确保七个星期标题严格对齐日期列。 + .accessibilityHidden(true) + } +} + +/// 日期 Canvas 的固定视觉参数。 +/// +/// 绘制与点击换算共用网格列数,日期、红框和五段标记据此对齐。 +private enum MonthCalendarCanvasLayout { + static let columnCount = 7 + static let selectedFontSize: CGFloat = 12 + static let regularFontSize: CGFloat = 10 + static let selectedColor = Color(red: 0.25, green: 0.62, blue: 1) + + static let todayFrameWidth: CGFloat = 20 + static let todayFrameMaximumHeight: CGFloat = 20 + static let todayFrameMinimumHeight: CGFloat = 16 + static let todayFrameVerticalInset: CGFloat = 8 + static let todayFrameCornerRadius: CGFloat = 5 + static let todayFrameLineWidth: CGFloat = 2.2 + /// 今天有日程时,红框需要在五段标记四周保留的视觉间距。 + static let todayFrameMarkerHorizontalPadding: CGFloat = 2 + static let todayFrameMarkerVerticalPadding: CGFloat = 1.5 + + static let periodMarkerWidth: CGFloat = 17 + static let periodMarkerGap: CGFloat = 0.55 + static let periodMarkerHeight: CGFloat = 3 + static let periodMarkerBottomInset: CGFloat = 4 + static let emptyPeriodColor = Color.white.opacity(0.3) + + static let gridLineWidth: CGFloat = 0.5 + static let gridColor = Color.white.opacity(0.065) +} + +/// 使用一张 Canvas 绘制整个月份,并通过触点坐标命中日期。 +/// +/// 相比 35/42 个 `Button`,这里只创建一个绘制节点和一个点击手势;垂直 +/// 拖动仍由外层系统 ScrollView 处理,因此不会引入自定义滚动物理。 +private struct MonthCalendarCanvas: View { + let model: MonthCalendarPageModel + let selectedDate: Date + let periodMarkers: [Int: MonthPeriodMarker] + let rowHeight: CGFloat + let select: (Date) -> Void + let onEmptyTap: () -> Void + + var body: some View { + GeometryReader { geometry in + Canvas(rendersAsynchronously: true) { context, size in + // 一次绘制共用日历快照,避免每个日期格重复读取系统日历。 + let calendar = Calendar.current + let columnWidth = size.width + / CGFloat(MonthCalendarCanvasLayout.columnCount) + drawGrid( + size: size, + columnWidth: columnWidth, + context: &context + ) + for (index, cell) in model.cells.enumerated() { + guard let cell else { continue } + let column = index % MonthCalendarCanvasLayout.columnCount + let row = index / MonthCalendarCanvasLayout.columnCount + let center = cellCenter( + column: column, + row: row, + columnWidth: columnWidth + ) + let isSelected = calendar.isDate( + cell.date, + inSameDayAs: selectedDate + ) + let isToday = calendar.isDateInToday(cell.date) + let periodMarker = periodMarkers[index] + + if isToday { + // 红框包围日期数字与五段标记的联合区域;无日程时只包围数字。 + drawTodayFrame( + at: center, + row: row, + includesPeriodMarker: periodMarker != nil, + context: &context + ) + } + + if let marker = periodMarker { + drawPeriodMarker( + marker, + row: row, + centerX: center.x, + context: &context + ) + } + + let label = dateLabel( + cell.text, + isSelected: isSelected + ) + context.draw( + label, + at: center, + anchor: .center + ) + } + } + .contentShape(Rectangle()) + .onTapGesture(coordinateSpace: .local) { location in + guard let date = date( + at: location, + canvasWidth: geometry.size.width + ) else { + onEmptyTap() + return + } + select(date) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel( + model.monthStart.formatted( + .dateTime + .year() + .month(.wide) + .locale(WatchWidgetShared.preferredLocale) + ) + ) + } + + /// 绘制与星期栏共用列宽的极淡网格,帮助快速确认日期所在行列。 + /// + /// 网格先于日期、今天红框和课程标记绘制,不参与点击命中,也不会改变 + /// Canvas 的尺寸。横线数量直接取月份模型行数,五行与六行月份都能对齐。 + private func drawGrid( + size: CGSize, + columnWidth: CGFloat, + context: inout GraphicsContext + ) { + let gridHeight = min( + size.height, + rowHeight * CGFloat(model.rowCount) + ) + var path = Path() + + for column in 0...MonthCalendarCanvasLayout.columnCount { + let x = CGFloat(column) * columnWidth + path.move(to: CGPoint(x: x, y: 0)) + path.addLine(to: CGPoint(x: x, y: gridHeight)) + } + for row in 0...model.rowCount { + let y = CGFloat(row) * rowHeight + path.move(to: CGPoint(x: 0, y: y)) + path.addLine(to: CGPoint(x: size.width, y: y)) + } + + context.stroke( + path, + with: .color(MonthCalendarCanvasLayout.gridColor), + lineWidth: MonthCalendarCanvasLayout.gridLineWidth + ) + } + + /// 计算日期格中心点;绘制、今天边框和节次标记共享同一坐标基准。 + private func cellCenter( + column: Int, + row: Int, + columnWidth: CGFloat + ) -> CGPoint { + CGPoint( + x: (CGFloat(column) + 0.5) * columnWidth, + y: (CGFloat(row) + 0.5) * rowHeight + ) + } + + /// 绘制今天的透明红框,不改变选中日期文字的蓝色语义。 + private func drawTodayFrame( + at center: CGPoint, + row: Int, + includesPeriodMarker: Bool, + context: inout GraphicsContext + ) { + let frameHeight = min( + MonthCalendarCanvasLayout.todayFrameMaximumHeight, + max( + MonthCalendarCanvasLayout.todayFrameMinimumHeight, + rowHeight - MonthCalendarCanvasLayout.todayFrameVerticalInset + ) + ) + let dateFrame = CGRect( + x: center.x - MonthCalendarCanvasLayout.todayFrameWidth / 2, + y: center.y - frameHeight / 2, + width: MonthCalendarCanvasLayout.todayFrameWidth, + height: frameHeight + ) + let frame: CGRect + if includesPeriodMarker { + // 五段标记靠近日期格底部,不能再用仅围绕数字的固定高度。 + // 对实际标记区域加少量留白后与数字框取并集,不移动日期、网格 + // 或标记本身,因此只改变今天红框的可视范围。 + let markerFrame = CGRect( + x: center.x - MonthCalendarCanvasLayout.periodMarkerWidth / 2, + y: CGFloat(row + 1) * rowHeight + - MonthCalendarCanvasLayout.periodMarkerBottomInset, + width: MonthCalendarCanvasLayout.periodMarkerWidth, + height: MonthCalendarCanvasLayout.periodMarkerHeight + ) + .insetBy( + dx: -MonthCalendarCanvasLayout.todayFrameMarkerHorizontalPadding, + dy: -MonthCalendarCanvasLayout.todayFrameMarkerVerticalPadding + ) + frame = dateFrame.union(markerFrame) + } else { + frame = dateFrame + } + context.stroke( + Path( + roundedRect: frame, + cornerRadius: MonthCalendarCanvasLayout.todayFrameCornerRadius + ), + with: .color(.red), + lineWidth: MonthCalendarCanvasLayout.todayFrameLineWidth + ) + } + + /// 创建日期文字;普通日期统一亮白,选中日期使用蓝色粗体并放大。 + private func dateLabel(_ text: String, isSelected: Bool) -> Text { + Text(text) + .font( + .system( + size: isSelected + ? MonthCalendarCanvasLayout.selectedFontSize + : MonthCalendarCanvasLayout.regularFontSize, + weight: isSelected ? .bold : .medium + ) + ) + .foregroundColor( + isSelected + ? MonthCalendarCanvasLayout.selectedColor + : .white + ) + } + + /// 在日期底部画总宽 17pt 的五段节次条。 + /// + /// 五段依次代表两节课,已有日程使用课程色,其余段使用暗白色。 + /// 整天没有日程时调用方不会绘制。 + private func drawPeriodMarker( + _ marker: MonthPeriodMarker, + row: Int, + centerX: CGFloat, + context: inout GraphicsContext + ) { + guard !marker.segmentCourses.isEmpty else { return } + let totalWidth = MonthCalendarCanvasLayout.periodMarkerWidth + let gap = MonthCalendarCanvasLayout.periodMarkerGap + let segmentCount = marker.segmentCourses.count + let segmentWidth = ( + totalWidth - gap * CGFloat(segmentCount - 1) + ) / CGFloat(segmentCount) + let startX = centerX - totalWidth / 2 + let markerY = CGFloat(row + 1) * rowHeight + - MonthCalendarCanvasLayout.periodMarkerBottomInset + + for index in marker.segmentCourses.indices { + let color = marker.segmentCourses[index]?.color + ?? MonthCalendarCanvasLayout.emptyPeriodColor + let rect = CGRect( + x: startX + CGFloat(index) * (segmentWidth + gap), + y: markerY, + width: segmentWidth, + height: MonthCalendarCanvasLayout.periodMarkerHeight + ) + context.fill( + Path(roundedRect: rect, cornerRadius: 1), + with: .color(color) + ) + } + } + + /// 将 Canvas 内的单次点击换算为 7 列网格索引。 + private func date(at location: CGPoint, canvasWidth: CGFloat) -> Date? { + guard location.x >= 0, + location.y >= 0, + rowHeight > 0, + canvasWidth > 0 + else { + return nil + } + let columnWidth = max( + 1, + canvasWidth / CGFloat(MonthCalendarCanvasLayout.columnCount) + ) + let column = min( + MonthCalendarCanvasLayout.columnCount - 1, + max(0, Int(location.x / columnWidth)) + ) + let row = min( + model.rowCount - 1, + max(0, Int(location.y / rowHeight)) + ) + let index = row * MonthCalendarCanvasLayout.columnCount + column + guard model.cells.indices.contains(index) else { return nil } + return model.cells[index]?.date + } +} diff --git a/watchOS/Views/Onboarding/WidgetInstallGuideView.swift b/watchOS/Views/Onboarding/WidgetInstallGuideView.swift new file mode 100644 index 00000000..4757ed45 --- /dev/null +++ b/watchOS/Views/Onboarding/WidgetInstallGuideView.swift @@ -0,0 +1,159 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import SwiftUI + +private enum WidgetInstallStep: Int, CaseIterable { + case hold + case edit + case choose + + var next: Self { Self(rawValue: (rawValue + 1) % Self.allCases.count)! } + var title: String { + switch self { + case .hold: watchLocalizedString("长按表盘") + case .edit: watchLocalizedString("编辑 → 复杂功能") + case .choose: watchLocalizedString("选择 XDYou") + } + } + var message: String { + switch self { + case .hold: watchLocalizedString("长按表盘空白处\n进入表盘编辑模式") + case .edit: watchLocalizedString("轻点“编辑”\n滑到“复杂功能”") + case .choose: watchLocalizedString("轻点一个组件位置\n选择 XDYou 小组件") + } + } +} + +/// 演示系统添加路径,不尝试跳转或控制表盘编辑器,也不把阅读完成当作已安装。 +struct WidgetInstallGuideView: View { + let playsAnimations: Bool + let didInteract: () -> Void + @State private var step = WidgetInstallStep.hold + @State private var holdProgress: CGFloat = 0 + @State private var manualRevision = 0 + + private var playbackID: Int? { playsAnimations ? manualRevision : nil } + + var body: some View { + WidgetIntroPage( + title: watchLocalizedString("把课表放到表盘上"), + message: step.message, + messageScale: 3.0 / 5.0 + ) { + GeometryReader { proxy in + let pictureHeight = max( + 0, + proxy.size.height - WidgetTutorialLayout.previewCaptionHeight + - WidgetTutorialLayout.sectionSpacing + ) + VStack(spacing: WidgetTutorialLayout.sectionSpacing) { + WidgetPreviewStage(width: 164, height: 134) { + illustration + } + .frame(width: proxy.size.width, height: pictureHeight) + .accessibilityHidden(true) + + Button(action: nextDemonstration) { + HStack(spacing: 4) { + Text(verbatim: "\(step.rawValue + 1) · \(step.title)") + .lineLimit(1) + .minimumScaleFactor(0.75) + Image(systemName: "chevron.right").font(.system(size: 8, weight: .bold)) + } + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.cyan) + .frame(width: proxy.size.width, height: WidgetTutorialLayout.previewCaptionHeight) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(watchLocalizedString("下一步添加示意")) + .accessibilityValue(step.title) + .accessibilityHint(watchLocalizedString("添加后自动更新,轻点组件即可进入概览。")) + } + .id(step) + .transition(.opacity) + } + } + .foregroundStyle(.white) + .multilineTextAlignment(.center) + .task(id: playbackID) { + guard playsAnimations else { + holdProgress = 1 + return + } + await runWidgetTutorialPlayback( + prepareCard: prepareDemonstration, + advanceCard: { step = step.next } + ) + } + } + + /// 环形示意先提交空进度再绘制填充;取消后不能启动离屏动画。 + private func prepareDemonstration() async { + guard step == .hold else { return } + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { holdProgress = 0 } + await Task.yield() + guard !Task.isCancelled else { return } + withAnimation(.linear(duration: 1.3)) { holdProgress = 1 } + } + + @ViewBuilder + private var illustration: some View { + switch step { + case .hold: + WidgetFacePreview(state: .ongoing) + .overlay { + Circle().trim(from: 0, to: holdProgress) + .stroke(.white, style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .frame(width: 27, height: 27) + .rotationEffect(.degrees(-90)) + .offset(x: 27, y: 17) + Image(systemName: "hand.point.up.left.fill") + .font(.system(size: 28)) + .foregroundStyle(.white) + .shadow(color: .black, radius: 3) + .offset(x: 36, y: 35) + } + case .edit: + WidgetFacePreview(state: .ongoing) + .scaleEffect(0.88) + .opacity(0.45) + .overlay { + RoundedRectangle(cornerRadius: 14) + .stroke(.cyan, lineWidth: 2) + .frame(width: 46, height: 46) + .offset(x: -44, y: -31) + Text(verbatim: watchLocalizedString("编辑")) + .font(.system(size: 11, weight: .semibold)) + .padding(.horizontal, 18) + .padding(.vertical, 5) + .background(.white.opacity(0.18), in: Capsule()) + .offset(y: 53) + } + case .choose: + VStack(spacing: 8) { + Text(verbatim: "XDYou") + .font(.system(size: WidgetTutorialLayout.textSize, weight: .semibold)) + .foregroundStyle(.cyan) + WidgetScreenshotPreview(image: .rectangularScheduleOngoing) + .frame(width: 146, height: 62) + Label(watchLocalizedString("综合课表"), systemImage: "checkmark.circle.fill") + .font(.system(size: WidgetTutorialLayout.textSize)) + } + .frame(width: 164, height: 134) + .background(.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 28)) + .overlay(RoundedRectangle(cornerRadius: 28).strokeBorder(.cyan.opacity(0.6), lineWidth: 1)) + } + } + + private func nextDemonstration() { + manualRevision &+= 1 + withAnimation(playsAnimations ? .easeInOut(duration: 0.25) : nil) { + step = step.next + } + didInteract() + } +} diff --git a/watchOS/Views/Onboarding/WidgetIntroPage.swift b/watchOS/Views/Onboarding/WidgetIntroPage.swift new file mode 100644 index 00000000..d481033a --- /dev/null +++ b/watchOS/Views/Onboarding/WidgetIntroPage.swift @@ -0,0 +1,289 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import SwiftUI + +/// 内容页共用固定的首行标题和两行底部说明,其间的有限高度全部交给展示区。 +struct WidgetIntroPage: View { + let title: String + let message: String + let messageScale: CGFloat + let messageIsCaption: Bool + let messageAction: (() -> Void)? + let content: Content + + init( + title: String, + message: String, + messageScale: CGFloat = 1, + messageIsCaption: Bool = false, + messageAction: (() -> Void)? = nil, + @ViewBuilder content: () -> Content + ) { + self.title = title + self.message = message + self.messageScale = messageScale + self.messageIsCaption = messageIsCaption + self.messageAction = messageAction + self.content = content() + } + + var body: some View { + GeometryReader { proxy in + let contentHeight = max( + 0, + proxy.size.height - WidgetTutorialLayout.titleHeight + - messageHeight - 2 * WidgetTutorialLayout.sectionSpacing + ) + VStack(spacing: WidgetTutorialLayout.sectionSpacing) { + Text(verbatim: title) + .font(.system(size: WidgetTutorialLayout.textSize, weight: .semibold)) + .lineLimit(1) + .frame(width: proxy.size.width, height: WidgetTutorialLayout.titleHeight) + .accessibilityAddTraits(.isHeader) + + content.frame(width: proxy.size.width, height: contentHeight) + + footer.frame(width: proxy.size.width, height: messageHeight) + } + .frame(width: proxy.size.width, height: proxy.size.height, alignment: .top) + } + .foregroundStyle(.white) + .multilineTextAlignment(.center) + .padding(.horizontal, WidgetTutorialLayout.horizontalInset) + } + + @ViewBuilder + private var footer: some View { + if let messageAction { + Button(action: messageAction) { + HStack(alignment: .firstTextBaseline, spacing: 4) { + explanation + Image(systemName: "chevron.right") + .font(.system(size: 8, weight: .bold)) + } + .foregroundStyle(.cyan) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(Text(verbatim: message)) + } else { + explanation.foregroundStyle(usesCaptionStyle ? Color.cyan : Color.white.opacity(0.85)) + } + } + + private var usesCaptionStyle: Bool { messageIsCaption || messageAction != nil } + + private var messageHeight: CGFloat { + // 字号与文字区同步缩放,腾出的高度交给上方示意图。 + (usesCaptionStyle ? WidgetTutorialLayout.captionMessageHeight : WidgetTutorialLayout.messageHeight) * messageScale + } + + private var explanation: some View { + Text(verbatim: message) + .font(.system( + size: (usesCaptionStyle ? 10.5 : WidgetTutorialLayout.textSize) * messageScale, + weight: .regular + )) + .lineLimit(2) + .minimumScaleFactor(usesCaptionStyle ? 0.75 : 1) + } +} + +/// 开场与结束独占整页,复用 App 实操教程的纯黑背景和斜向扫光文字。 +struct WidgetOnboardingTransitionPage: View { + let title: String + let message: String + let playsAnimations: Bool + var continueAction: (() -> Void)? = nil + + var body: some View { + Group { + if let continueAction { + Button(action: continueAction) { content } + .buttonStyle(.plain) + } else { + content.accessibilityElement(children: .combine) + } + } + .environment(\.watchOnboardingAnimationsPaused, !playsAnimations) + } + + private var content: some View { + ZStack { + Color.black.ignoresSafeArea() + VStack(spacing: 18) { + WatchOnboardingSweepingLightText( + text: title, + font: .system(size: WidgetTutorialLayout.textSize, weight: .semibold), + baseOpacity: 1 + ) + .accessibilityAddTraits(.isHeader) + WatchOnboardingSweepingLightText( + text: message, + font: .system(size: WidgetTutorialLayout.textSize, weight: .medium), + baseOpacity: 0.76 + ) + } + .padding(.horizontal, 22) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + } +} + +struct WidgetScheduleGuidePage: View { + let image: WidgetTutorialImage + let cycleImage: () -> Void + + var body: some View { + WidgetIntroPage( + title: watchLocalizedString("抬腕,就知道下一节"), + message: watchLocalizedString("课中看时间和进度\n课后显示下一节课") + ) { + WidgetRotatingPreview(image: image, cycleImage: cycleImage) + } + } +} + +/// 图片区域整体可点,说明只占一行,把余下高度交给原比例的组件截图。 +private struct WidgetRotatingPreview: View { + let image: WidgetTutorialImage + let cycleImage: () -> Void + + var body: some View { + Button(action: cycleImage) { + GeometryReader { proxy in + let pictureHeight = max( + 0, + proxy.size.height - WidgetTutorialLayout.previewCaptionHeight + - WidgetTutorialLayout.sectionSpacing + ) + VStack(spacing: WidgetTutorialLayout.sectionSpacing) { + WidgetTutorialCardPreview(image: image) + .frame(width: proxy.size.width, height: pictureHeight) + + HStack(spacing: 4) { + Text(verbatim: [image.family.title, image.state.title].joined(separator: " · ")) + .lineLimit(1) + .minimumScaleFactor(0.75) + Image(systemName: "chevron.right").font(.system(size: 8, weight: .bold)) + } + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.cyan) + .frame(width: proxy.size.width, height: WidgetTutorialLayout.previewCaptionHeight) + } + // 图片与形态/状态标签作为一组过渡,避免切换时文字先于图片变化。 + .id(image) + .transition(.opacity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(watchLocalizedString("切换组件示例")) + .accessibilityValue(Text(verbatim: image.accessibilitySummary)) + .accessibilityHint(watchLocalizedString("轻点图片查看其他组件和状态。")) + } +} + +struct WidgetSizeGuidePage: View { + let playsAnimations: Bool + let didSelect: () -> Void + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var family = WidgetPreviewFamily.circular + @State private var exampleIndex = 0 + @State private var manualRevision = 0 + + // 自动推进保持同一任务;手动选择或播放条件变化才重置阅读计时。 + private var playbackID: Int? { playsAnimations ? manualRevision : nil } + + private var example: WidgetTutorialImage { + family.examples[min(exampleIndex, family.examples.count - 1)] + } + + var body: some View { + WidgetIntroPage( + title: watchLocalizedString("选择适合你的表盘"), + message: [example.caption, example.guideDescription].joined(separator: "\n"), + messageIsCaption: true, + messageAction: family.examples.count > 1 ? cycleExample : nil + ) { + GeometryReader { proxy in + let selectorHeight: CGFloat = 32 + let pictureHeight = max(0, proxy.size.height - selectorHeight - WidgetTutorialLayout.sectionSpacing) + VStack(spacing: WidgetTutorialLayout.sectionSpacing) { + if family.examples.count > 1 { + Button(action: cycleExample) { + selectedExample + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .frame(width: proxy.size.width, height: pictureHeight) + .accessibilityLabel(watchLocalizedString("切换组件示例")) + .accessibilityValue(Text(verbatim: example.accessibilitySummary)) + .accessibilityHint(watchLocalizedString("轻点图片查看其他组件和状态。")) + } else { + selectedExample + .frame(width: proxy.size.width, height: pictureHeight) + } + familySelector.frame(width: proxy.size.width, height: selectorHeight) + } + } + } + .task(id: playbackID) { + guard playsAnimations else { return } + await runWidgetTutorialPlayback(advanceCard: advanceExample) + } + } + + private var familySelector: some View { + HStack(spacing: 6) { + ForEach(WidgetPreviewFamily.allCases) { candidate in + Button { + manualRevision &+= 1 + withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.22)) { + family = candidate + exampleIndex = 0 + } + didSelect() + } label: { + VStack(spacing: 2) { + Image(systemName: candidate.symbol) + .font(.system(size: 13, weight: .medium)) + Text(verbatim: candidate.title) + .font(.system(size: 9, weight: .medium)) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .foregroundStyle(family == candidate ? Color.cyan : Color.white.opacity(0.72)) + .background(family == candidate ? Color.cyan.opacity(0.15) : Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + .accessibilityLabel(candidate.title) + .accessibilityHint(candidate.message) + .accessibilityAddTraits(family == candidate ? [.isSelected] : []) + } + } + } + + private var selectedExample: some View { + WidgetTutorialCardPreview(image: example) + .id(example) + .transition(.opacity) + } + + private func cycleExample() { + manualRevision &+= 1 + withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.22)) { + advanceExample() + } + didSelect() + } + + private func advanceExample() { + exampleIndex = (exampleIndex + 1) % family.examples.count + } +} diff --git a/watchOS/Views/Onboarding/WidgetOnboardingView.swift b/watchOS/Views/Onboarding/WidgetOnboardingView.swift new file mode 100644 index 00000000..32e5473c --- /dev/null +++ b/watchOS/Views/Onboarding/WidgetOnboardingView.swift @@ -0,0 +1,350 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import SwiftUI + +enum WidgetTutorialPage: Int, CaseIterable, Identifiable { + case introduction + case schedule + case sizes + case install + case completion + + var id: Int { rawValue } +} + +/// 标题紧接状态栏;底部说明与分页点分区排列,中间使用剩余视口。 +enum WidgetTutorialLayout { + static let minimumTopInset: CGFloat = 36 + static let horizontalInset: CGFloat = 8 + static let textSize: CGFloat = 17 + static let titleHeight: CGFloat = 26 + static let titleLift: CGFloat = titleHeight / 2 + static let messageHeight: CGFloat = 44 + static let captionMessageHeight: CGFloat = 32 + static let sectionSpacing: CGFloat = 8 + static let previewCaptionHeight: CGFloat = 22 + static let cardReferenceSize = CGSize(width: 164, height: 70) + static let cardDisplayDuration: Duration = .seconds(3) + static let messageToIndicatorsSpacing: CGFloat = 10 + static let pageIndicatorHeight: CGFloat = 24 + static let pageIndicatorBottomInset: CGFloat = 6 + static let pageIndicatorLift: CGFloat = 6 + + static var bottomInset: CGFloat { + messageToIndicatorsSpacing + pageIndicatorHeight + pageIndicatorBottomInset + } +} + +/// 三个示例页共用可取消的循环;手动操作通过 `.task(id:)` 重建阅读计时。 +/// `prepareCard` 只准备当前卡片的演示,翻页和退出时由宿主任务一并取消。 +@MainActor +func runWidgetTutorialPlayback( + prepareCard: @MainActor () async -> Void = {}, + advanceCard: @MainActor () -> Void +) async { + while !Task.isCancelled { + await prepareCard() + guard !Task.isCancelled else { return } + do { try await Task.sleep(for: WidgetTutorialLayout.cardDisplayDuration) } + catch { return } + guard !Task.isCancelled else { return } + withAnimation(.easeInOut(duration: 0.25)) { advanceCard() } + } +} + +/// 阅读型教程独立于实操输入桥;系统分页处理滑动,表冠和辅助功能共用选择入口。 +struct WidgetOnboardingView: View { + let finish: () -> Void + @Environment(\.scenePhase) private var scenePhase + @Environment(\.locale) private var locale + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.accessibilityVoiceOverEnabled) private var voiceOverEnabled + @State private var page = WidgetTutorialPage.introduction + @State private var crownPosition = 0.0 + @State private var previewIndex = 0 + @State private var manualPreviewRevision = 0 + @State private var didFinish = false + @State private var introductionSlideID: UUID? + @State private var introductionSlideProgress: CGFloat = 0 + @FocusState private var crownFocused: Bool + + // 三种课中形态,以及圆形和长方形的次日状态。 + private static let schedulePreviews: [WidgetTutorialImage] = [ + .circularScheduleOngoing, .cornerScheduleOngoing, .rectangularScheduleOngoing, + .circularScheduleTomorrow, .rectangularScheduleTomorrow, + ] + + private var previewImage: WidgetTutorialImage { + Self.schedulePreviews[previewIndex % Self.schedulePreviews.count] + } + + private var canAnimate: Bool { + scenePhase == .active && !reduceMotion && !voiceOverEnabled && introductionSlideID == nil + } + + private struct PlaybackID: Equatable { + let page: WidgetTutorialPage? + let revision: Int + } + + private var playbackID: PlaybackID { + PlaybackID( + page: canAnimate && page == .schedule ? page : nil, + revision: manualPreviewRevision + ) + } + + private var completionIsActive: Bool { + page == .completion && scenePhase == .active + } + + var body: some View { + GeometryReader { proxy in + let topInset = max(WidgetTutorialLayout.minimumTopInset, proxy.safeAreaInsets.top) + ZStack { + Color.black + pages(in: proxy.size, topInset: topInset) + .allowsHitTesting(introductionSlideID == nil) + .accessibilityHidden(introductionSlideID != nil) + + if let introductionSlideID { + introductionSlide(in: proxy.size, topInset: topInset) + .id(introductionSlideID) + .transition(.identity) + .onAppear { animateIntroductionSlide(id: introductionSlideID) } + } + } + .frame(width: proxy.size.width, height: proxy.size.height) + } + .ignoresSafeArea() + .environment(\.colorScheme, .dark) + .onAppear(perform: reclaimCrownFocus) + .onChange(of: page) { _, selected in + crownPosition = Double(selected.rawValue) + previewIndex = 0 + WatchHaptics.selection() + reclaimCrownFocus() + } + .onChange(of: crownPosition) { _, position in + guard position.isFinite else { return } + selectPage(at: Int(position.rounded())) + } + .onChange(of: scenePhase) { _, phase in + if phase == .active { + reclaimCrownFocus() + } else if let introductionSlideID { + finishIntroductionSlide(id: introductionSlideID) + } + } + .onDisappear { + if let introductionSlideID { + finishIntroductionSlide(id: introductionSlideID, restoresFocus: false) + } + } + .task(id: playbackID) { + guard playbackID.page != nil else { return } + await runWidgetTutorialPlayback(advanceCard: advancePreview) + } + .task(id: completionIsActive) { + // TabView 会预加载相邻页,因此由真实选中页控制计时,不能依赖 onAppear。 + guard completionIsActive, !didFinish else { return } + do { try await Task.sleep(for: .seconds(2)) } + catch { return } + guard !Task.isCancelled, completionIsActive, !didFinish else { return } + didFinish = true + finish() + } + } + + private func pages(in viewport: CGSize, topInset: CGFloat) -> some View { + TabView(selection: $page) { + introductionPage(in: viewport) + .tag(WidgetTutorialPage.introduction) + + schedulePage(in: viewport, topInset: topInset) + .tag(WidgetTutorialPage.schedule) + + contentPage(in: viewport, topInset: topInset) { + WidgetSizeGuidePage( + playsAnimations: canAnimate && page == .sizes, + didSelect: reclaimCrownFocus + ) + } + .tag(WidgetTutorialPage.sizes) + + contentPage(in: viewport, topInset: topInset) { + WidgetInstallGuideView( + playsAnimations: canAnimate && page == .install, + didInteract: reclaimCrownFocus + ) + } + .tag(WidgetTutorialPage.install) + + WidgetOnboardingTransitionPage( + title: watchLocalizedString("教程结束"), + message: watchLocalizedString("开始愉快的使用吧"), + playsAnimations: canAnimate && page == .completion + ) + .frame(width: viewport.width, height: viewport.height) + .tag(WidgetTutorialPage.completion) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .ignoresSafeArea() + .frame(width: viewport.width, height: viewport.height) + .id(locale.identifier) + .focusable() + .focused($crownFocused) + .digitalCrownRotation( + $crownPosition, + from: 0, + through: Double(WidgetTutorialPage.allCases.count - 1), + by: 1, + sensitivity: .low, + isContinuous: false, + isHapticFeedbackEnabled: false + ) + .accessibilityAdjustableAction { direction in + switch direction { + case .increment: selectPage(at: page.rawValue + 1) + case .decrement: selectPage(at: page.rawValue - 1) + @unknown default: break + } + } + } + + private func introductionPage(in viewport: CGSize) -> some View { + WidgetOnboardingTransitionPage( + title: watchLocalizedString("还有一个更快的方法"), + message: watchLocalizedString("轻点以继续"), + playsAnimations: canAnimate && page == .introduction, + continueAction: startIntroductionSlide + ) + .frame(width: viewport.width, height: viewport.height) + } + + private func schedulePage(in viewport: CGSize, topInset: CGFloat) -> some View { + contentPage(in: viewport, topInset: topInset) { + WidgetScheduleGuidePage(image: previewImage, cycleImage: cyclePreview) + } + } + + /// 首页轻点时明确平移两页,结束后交还系统分页;两处复用完全相同的页面布局。 + private func introductionSlide(in viewport: CGSize, topInset: CGFloat) -> some View { + HStack(spacing: 0) { + introductionPage(in: viewport) + schedulePage(in: viewport, topInset: topInset) + } + .frame(width: viewport.width * 2, height: viewport.height) + .offset(x: -viewport.width * introductionSlideProgress) + .frame(width: viewport.width, height: viewport.height, alignment: .leading) + .clipped() + .background(.black) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + + private func startIntroductionSlide() { + guard page == .introduction, introductionSlideID == nil, scenePhase == .active else { return } + guard !reduceMotion else { + selectPage(at: WidgetTutorialPage.schedule.rawValue) + return + } + introductionSlideProgress = 0 + introductionSlideID = UUID() + crownFocused = false + } + + private func animateIntroductionSlide(id: UUID) { + guard introductionSlideID == id else { return } + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { page = .schedule } + withAnimation(.easeInOut(duration: 0.38), completionCriteria: .removed) { + introductionSlideProgress = 1 + } completion: { + finishIntroductionSlide(id: id) + } + } + + private func finishIntroductionSlide(id: UUID, restoresFocus: Bool = true) { + guard introductionSlideID == id else { return } + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + page = .schedule + crownPosition = Double(WidgetTutorialPage.schedule.rawValue) + introductionSlideID = nil + introductionSlideProgress = 0 + } + if restoresFocus { reclaimCrownFocus() } + } + + private var pageIndicators: some View { + HStack(spacing: 0) { + ForEach(WidgetTutorialPage.allCases) { candidate in + Button { selectPage(at: candidate.rawValue) } label: { + Circle() + .fill(candidate == page ? Color.cyan : Color.white.opacity(0.28)) + .frame(width: candidate == page ? 7 : 5, height: candidate == page ? 7 : 5) + // 只上移圆点,保留底栏高度和点击区域,不牵动正文位置。 + .offset(y: -WidgetTutorialLayout.pageIndicatorLift) + .frame(width: 24, height: WidgetTutorialLayout.pageIndicatorHeight) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(watchLocalizedFormat( + "第 %lld 页,共 %lld 页", Int64(candidate.rawValue + 1), Int64(WidgetTutorialPage.allCases.count) + )) + .accessibilityAddTraits(candidate == page ? [.isSelected] : []) + } + } + } + + private func contentPage( + in viewport: CGSize, + topInset: CGFloat, + @ViewBuilder _ content: () -> Content + ) -> some View { + // 正文与分页点在同一个 Tab 内顺序布局,系统分页的安全区不能把两者叠到一起。 + // 按标题的实际起点分配展示区高度,避免文字偏移后留下多余占位;底栏保持固定。 + let contentTopInset = max(0, topInset - WidgetTutorialLayout.titleLift) + return VStack(spacing: WidgetTutorialLayout.messageToIndicatorsSpacing) { + content() + .frame( + width: viewport.width, + height: max(0, viewport.height - contentTopInset - WidgetTutorialLayout.bottomInset) + ) + pageIndicators + .frame(height: WidgetTutorialLayout.pageIndicatorHeight) + } + .padding(.top, contentTopInset) + .padding(.bottom, WidgetTutorialLayout.pageIndicatorBottomInset) + .frame(width: viewport.width, height: viewport.height, alignment: .top) + } + + private func selectPage(at index: Int) { + guard introductionSlideID == nil, + let next = WidgetTutorialPage(rawValue: index), next != page else { return } + withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.24)) { + page = next + } + } + + private func reclaimCrownFocus() { + guard !voiceOverEnabled, scenePhase == .active, introductionSlideID == nil else { return } + crownFocused = true + } + + private func advancePreview() { + previewIndex = (previewIndex + 1) % Self.schedulePreviews.count + } + + private func cyclePreview() { + manualPreviewRevision &+= 1 + withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.25)) { + advancePreview() + } + reclaimCrownFocus() + } +} diff --git a/watchOS/Views/Onboarding/WidgetPreviewView.swift b/watchOS/Views/Onboarding/WidgetPreviewView.swift new file mode 100644 index 00000000..808f7587 --- /dev/null +++ b/watchOS/Views/Onboarding/WidgetPreviewView.swift @@ -0,0 +1,268 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import SwiftUI + +/// 与用户提供的截图对应;教学图片不请求课表、不写缓存,也不参与真实组件刷新。 +enum WidgetPreviewState: Int, CaseIterable { + case ongoing + case tomorrow + + var time: String { self == .ongoing ? "21:15" : "08:30" } + var startTime: String { self == .ongoing ? "20:00" : "08:30" } + var endTime: String { self == .ongoing ? "21:15" : "10:05" } + var location: String { self == .ongoing ? "B-312" : "B-442" } + var teacher: String { self == .ongoing ? "宋浩" : "苏玉龙" } + var courseName: String { + watchLocalizedString(self == .ongoing ? "高等数学" : "工程概论 (IV)") + } + var title: String { + watchLocalizedString(self == .ongoing ? "正在上课" : "今日已下课") + } + var heading: String { + self == .ongoing + ? watchLocalizedString("下课") + : watchLocalizedFormat("明天%@", watchLocalizedString("上课")) + } + var accessibilitySummary: String { + [heading, time, location].joined(separator: ",") + } +} + +/// 教程所展示的组件用途,与截图文件名解耦,供说明和辅助功能共用。 +enum WidgetPreviewRole { + case combined, name, timeAndPlace, overview + + var title: String { + switch self { + case .combined: watchLocalizedString("综合课表") + case .name: watchLocalizedString("课程名称") + case .timeAndPlace: watchLocalizedString("时间地点") + case .overview: watchLocalizedString("日程概览") + } + } +} + +/// 每种形态只列出已经提供截图的示例;表角只有课中综合课表这一张。 +enum WidgetPreviewFamily: Int, CaseIterable, Identifiable { + case circular, corner, rectangular + + var id: Int { rawValue } + var title: String { + switch self { + case .circular: watchLocalizedString("圆形") + case .corner: watchLocalizedString("表角") + case .rectangular: watchLocalizedString("长方形") + } + } + var symbol: String { + switch self { + case .circular: "circle" + case .corner: "arrow.up.left" + case .rectangular: "rectangle" + } + } + var message: String { + switch self { + case .circular: watchLocalizedString("圆形:快速查看时间与地点。") + case .corner: watchLocalizedString("表角:沿表盘边缘查看课程与进度。") + case .rectangular: watchLocalizedString("长方形:课程、时间、地点与教师更完整。") + } + } + var examples: [WidgetTutorialImage] { + switch self { + case .circular: + [.circularScheduleOngoing, .circularScheduleTomorrow, + .circularNameOngoing, .circularNameTomorrow, + .circularTimeOngoing, .circularTimeTomorrow, + .circularOverviewOngoing, .circularOverviewTomorrow] + case .corner: + [.cornerScheduleOngoing] + case .rectangular: + [.rectangularScheduleOngoing, .rectangularScheduleTomorrow, + .rectangularNameOngoing, .rectangularNameTomorrow, + .rectangularTimeOngoing, .rectangularOverviewOngoing, + .rectangularOverviewTomorrow] + } + } +} + +/// 资源名集中管理。保留截图原色,避免图片随教程按钮的强调色被重新着色。 +enum WidgetTutorialImage: String { + case circularScheduleOngoing = "WidgetGuideCircularScheduleOngoing" + case circularScheduleTomorrow = "WidgetGuideCircularScheduleTomorrow" + case circularNameOngoing = "WidgetGuideCircularNameOngoing" + case circularNameTomorrow = "WidgetGuideCircularNameTomorrow" + case circularTimeOngoing = "WidgetGuideCircularTimeOngoing" + case circularTimeTomorrow = "WidgetGuideCircularTimeTomorrow" + case circularOverviewOngoing = "WidgetGuideCircularOverviewOngoing" + case circularOverviewTomorrow = "WidgetGuideCircularOverviewTomorrow" + case rectangularScheduleOngoing = "WidgetGuideRectangularScheduleOngoing" + case rectangularScheduleTomorrow = "WidgetGuideRectangularScheduleTomorrow" + case rectangularNameOngoing = "WidgetGuideRectangularNameOngoing" + case rectangularNameTomorrow = "WidgetGuideRectangularNameTomorrow" + case rectangularTimeOngoing = "WidgetGuideRectangularTimeOngoing" + case rectangularOverviewOngoing = "WidgetGuideRectangularOverviewOngoing" + case rectangularOverviewTomorrow = "WidgetGuideRectangularOverviewTomorrow" + case cornerScheduleOngoing = "WidgetGuideCornerScheduleOngoing" + case faceCircularOngoing = "WidgetGuideFaceCircularOngoing" + case faceRectangularOngoing = "WidgetGuideFaceRectangularOngoing" + + var state: WidgetPreviewState { + switch self { + case .circularScheduleTomorrow, .circularNameTomorrow, + .circularTimeTomorrow, .circularOverviewTomorrow, + .rectangularScheduleTomorrow, .rectangularNameTomorrow, + .rectangularOverviewTomorrow: .tomorrow + default: .ongoing + } + } + var family: WidgetPreviewFamily { + switch self { + case .cornerScheduleOngoing: .corner + case .rectangularScheduleOngoing, .rectangularScheduleTomorrow, + .rectangularNameOngoing, .rectangularNameTomorrow, + .rectangularTimeOngoing, .rectangularOverviewOngoing, + .rectangularOverviewTomorrow, .faceRectangularOngoing: .rectangular + default: .circular + } + } + var role: WidgetPreviewRole { + switch self { + case .circularNameOngoing, .circularNameTomorrow, + .rectangularNameOngoing, .rectangularNameTomorrow: .name + case .circularTimeOngoing, .circularTimeTomorrow, .rectangularTimeOngoing: .timeAndPlace + case .circularOverviewOngoing, .circularOverviewTomorrow, + .rectangularOverviewOngoing, .rectangularOverviewTomorrow: .overview + default: .combined + } + } + var caption: String { [role.title, state.title].joined(separator: " · ") } + + var guideDescription: String { + switch role { + case .combined: + switch family { + case .corner: watchLocalizedString("沿表盘边缘看课程与进度") + case .rectangular: watchLocalizedString("课程、时间、地点与教师") + case .circular: timeAndPlaceDescription + } + case .name: + watchLocalizedString("当前或下一节课的名称") + case .timeAndPlace: + timeAndPlaceDescription + case .overview: + watchLocalizedString("今日剩余安排与结束状态") + } + } + + private var timeAndPlaceDescription: String { + watchLocalizedString(state == .ongoing ? "下课时间、地点与进度" : "下次上课的时间与地点") + } + + var accessibilitySummary: String { + var details = [family.title, role.title, state.title] + switch role { + case .name: + details.append(state.courseName) + case .overview: + if state == .ongoing { + details.append(watchLocalizedFormat("今日还剩 %lld 项", Int64(1))) + if family == .rectangular { + details.append(watchLocalizedFormat("%@ 全部结束", state.endTime)) + } + } else { + details.append(watchLocalizedFormat("明天%@", state.time)) + } + if family == .rectangular { + details.append(watchLocalizedFormat("%@ %d 项", watchLocalizedString("本周"), 6)) + } + case .combined, .timeAndPlace: + if role == .combined { details.append(state.courseName) } + if family == .rectangular { + details += [watchLocalizedString("上课"), state.startTime, + watchLocalizedString("下课"), state.endTime, + state.location, state.teacher] + } else if family == .corner { + details += [watchLocalizedString("课程进度"), state.location] + } else { + details.append(state.accessibilitySummary) + } + } + return details.joined(separator: ",") + } +} + +struct WidgetScreenshotPreview: View { + let image: WidgetTutorialImage + + var body: some View { + Image(image.rawValue) + .renderingMode(.original) + .resizable() + .scaledToFit() + .accessibilityLabel(Text(verbatim: image.accessibilitySummary)) + } +} + +/// 所有独立组件共用长方形画布,等高缩放并保留原图比例。 +struct WidgetTutorialCardPreview: View { + let image: WidgetTutorialImage + + var body: some View { + WidgetPreviewStage( + width: WidgetTutorialLayout.cardReferenceSize.width, + height: WidgetTutorialLayout.cardReferenceSize.height + ) { + WidgetScreenshotPreview(image: image) + } + } +} + +/// 在有限教学视口内等比缩放,保持截图中各行文字、图标和进度条的原始比例。 +struct WidgetPreviewStage: View { + let referenceSize: CGSize + let content: Content + + init(width: CGFloat, height: CGFloat, @ViewBuilder content: () -> Content) { + referenceSize = CGSize(width: width, height: height) + self.content = content() + } + + var body: some View { + GeometryReader { proxy in + let scale = max(0.01, min( + proxy.size.width / referenceSize.width, + proxy.size.height / referenceSize.height + )) + content + .frame(width: referenceSize.width, height: referenceSize.height) + .scaleEffect(scale) + .position(x: proxy.size.width / 2, y: proxy.size.height / 2) + } + } +} + +/// 只组合 XDYou 的真实组件截图;外框和操作提示仍由教程绘制。 +struct WidgetFacePreview: View { + let state: WidgetPreviewState + + var body: some View { + VStack(spacing: 6) { + HStack { + WidgetScreenshotPreview(image: state == .ongoing ? .faceCircularOngoing : .circularScheduleTomorrow) + .frame(width: 46, height: 46) + Spacer(minLength: 4) + WidgetScreenshotPreview(image: state == .ongoing ? .circularOverviewOngoing : .circularOverviewTomorrow) + .frame(width: 46, height: 46) + } + WidgetScreenshotPreview(image: state == .ongoing ? .faceRectangularOngoing : .rectangularScheduleTomorrow) + .frame(width: 146, height: 62) + } + .padding(9) + .frame(width: 164, height: 134) + .background(.black, in: RoundedRectangle(cornerRadius: 22)) + .overlay(RoundedRectangle(cornerRadius: 22).strokeBorder(.white.opacity(0.22), lineWidth: 1)) + .accessibilityElement(children: .combine) + } +} diff --git a/watchOS/Views/OverviewScheduleView.swift b/watchOS/Views/OverviewScheduleView.swift new file mode 100644 index 00000000..39d5267a --- /dev/null +++ b/watchOS/Views/OverviewScheduleView.swift @@ -0,0 +1,178 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import SwiftUI + +/// 概览以今日摘要为入口,最多展示当前与下一项,其余安排只做统计。 +struct OverviewScheduleView: View { + @EnvironmentObject private var store: WatchScheduleStore + let onCrownInteraction: () -> Void + let onCrownInput: () -> Void + let onTouchInput: () -> Void + var alwaysAllowsTeachingBounce = false + /// 只在“概览·上下滑动”教学步骤中让触摸实际带动短内容。 + var drivesTeachingTouchScroll = false + var inputContext = 0 + + var body: some View { + InteractionAwareScrollView( + onScroll: onCrownInteraction, + onCrownInput: onCrownInput, + onTouchInput: onTouchInput, + centersShortContent: false, + alwaysAllowsBounce: alwaysAllowsTeachingBounce, + usesShortContentTouchFallback: alwaysAllowsTeachingBounce, + inputContext: inputContext, + teachingTouchScrollEffect: drivesTeachingTouchScroll + ? .elastic + : .disabled, + protectsInitialTopEdge: true + ) { + TimelineView(.explicit(store.presentationTimelineDates)) { context in + let presentation = store.presentation(at: context.date) + let summary = WatchOverviewSummary(presentation) + VStack(alignment: .leading, spacing: 12) { + todaySummary(summary.today, presentation: presentation) + .padding(.trailing, 34) + + if let current = summary.current { + featuredCourse(current, isCurrent: true, presentation: presentation) + } + if let next = summary.next { + featuredCourse(next, isCurrent: false, presentation: presentation) + } + + if presentation.state == .noMoreCourses { + Text(presentation.title) + .font(.caption2) + .foregroundStyle(.secondary) + } else if let week = summary.week { + weekSummary(week) + } else if summary.today != nil && summary.next == nil { + Text(watchLocalizedString("后续课表待同步")) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 2) + .padding(.top, 2) + .padding(.bottom, 12) + .environment(\.calendar, presentation.calendar) + .environment(\.timeZone, presentation.calendar.timeZone) + } + } + } + + private func todaySummary( + _ day: WatchOverviewSummary.Day?, presentation: WatchSchedulePresentation + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(presentation.date, format: .dateTime.month().day().weekday()) + .font(.caption2) + .foregroundStyle(.secondary) + + if let day { + Text(todayTitle(day)) + .font(.headline) + .fixedSize(horizontal: false, vertical: true) + + if day.all.total > 0 { + // 已全部结束时显示今天完成的构成,不摆三个“剩余 0”。 + Text(countsText(day.remaining.total > 0 ? day.remaining : day.all)) + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + if day.completedCount > 0 && day.remaining.total > 0 { + Text(watchLocalizedFormat("已完成 %d 项", day.completedCount)) + .font(.caption2) + .foregroundStyle(.secondary) + } + if let end = day.additionalEndTime { + Label( + watchLocalizedFormat("%@ 全部结束", + presentation.compactDateTimeText(for: end)), + systemImage: "flag.checkered") + .font(.caption2) + .foregroundStyle(.secondary) + } + } else { + Text([.noData, .signedOut, .expired, .semesterUpcoming, .semesterEnded] + .contains(presentation.state) + ? presentation.title : watchLocalizedString("今日概览待同步")) + .font(.headline) + .fixedSize(horizontal: false, vertical: true) + if [.noData, .signedOut, .expired, .unconfirmed].contains(presentation.state) { + Text(watchLocalizedString("打开手机更新课表")) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } + + private func todayTitle(_ day: WatchOverviewSummary.Day) -> String { + if day.all.total == 0 { return watchLocalizedString("今日没有安排") } + if day.remaining.total == 0 { return watchLocalizedString("今日安排已完成") } + return watchLocalizedFormat("今日还剩 %d 项", day.remaining.total) + } + + private func countsText(_ counts: WatchOverviewSummary.Counts) -> String { + watchLocalizedFormat("课程 %d · 考试 %d · 实验 %d", + counts.courses, counts.exams, counts.experiments) + } + + /// 时间和元数据只在卡片中出现,同一天的日期也不重复显示。 + private func featuredCourse( + _ course: WatchCourse, isCurrent: Bool, presentation: WatchSchedulePresentation + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(courseContext(course, isCurrent: isCurrent)) + .font(.caption.weight(.semibold)) + .foregroundStyle(course.color) + CourseRow( + course: course, + showsDate: !presentation.calendar.isDate(course.startAt, inSameDayAs: presentation.date), + showsInlineMetadata: true) + } + } + + private func courseContext(_ course: WatchCourse, isCurrent: Bool) -> String { + switch course.kind { + case "exam": + return watchLocalizedString(isCurrent ? "正在考试" : "下一场考试") + case "physicsExperiment", "otherExperiment": + return watchLocalizedString(isCurrent ? "正在实验" : "下一项实验") + default: + return watchLocalizedString(isCurrent ? "正在上课" : "下一节") + } + } + + private func weekSummary(_ week: WatchOverviewSummary.Week) -> some View { + VStack(alignment: .leading, spacing: 5) { + Divider() + Text(week.remainingDays > 0 + ? watchLocalizedFormat("本周还有 %d 天安排", week.remainingDays) + : watchLocalizedString("本周没有后续安排")) + .font(.caption.weight(.semibold)) + + if week.remainingDays > 0 { + Label(week.upcoming.exams > 0 + ? watchLocalizedFormat("待考 %d 场", week.upcoming.exams) + : watchLocalizedString("本周暂无待考"), + systemImage: "pencil.and.list.clipboard") + .font(.caption2) + .foregroundStyle(.secondary) + if week.upcoming.experiments > 0 { + Label( + watchLocalizedFormat("待做实验 %d 项", week.upcoming.experiments), + systemImage: "flask") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/watchOS/Views/RootScheduleView.swift b/watchOS/Views/RootScheduleView.swift new file mode 100644 index 00000000..5b700245 --- /dev/null +++ b/watchOS/Views/RootScheduleView.swift @@ -0,0 +1,2308 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import SwiftUI + +/// 手表课表支持的五种顶层展示方式。 +enum WatchCalendarMode: String, CaseIterable, Identifiable { + case overview + case courseList + case day + case week + case month + + var id: String { rawValue } + + /// 日、周、月页面共享同一个当前日期锚点。 + var usesSelectedDate: Bool { + switch self { + case .day, .week, .month: + true + case .overview, .courseList: + false + } + } + + /// 首次进入时需要把悬浮按钮让给内容的页面。 + var hidesFloatingControlsOnEntry: Bool { + self == .day || self == .month + } + + /// 视图选择列表中的本地化名称。 + var title: String { + switch self { + case .overview: + watchLocalizedString("概览") + case .courseList: + watchLocalizedString("课程列表") + case .day: + watchLocalizedString("日视图") + case .month: + watchLocalizedString("月视图") + case .week: + watchLocalizedString("周视图") + } + } + + /// 视图选择列表中的 SF Symbol。 + var systemImage: String { + switch self { + case .overview: + "forward.end" + case .courseList: + "list.bullet" + case .day: + "calendar.day.timeline.left" + case .month: + "calendar.circle" + case .week: + "calendar" + } + } +} + +/// 根页面的响应式布局参数。 +/// +/// 集中维护尺寸计算可以防止不同表径下的按钮与内容各自使用一套比例。 +enum RootScheduleLayout { + static let controlContentSize: CGFloat = 20 + /// `.controlSize(.small)` 在表盘上的近似外径;教学脉冲用它计算真实中心。 + static let controlVisualDiameter: CGFloat = 32 + static let completionBottomInset: CGFloat = 31 + static let cachedScheduleNoticeBottomInset: CGFloat = 42 + static let onboardingNoticeBottomInset: CGFloat = 42 + + /// 过期提示固定在整个表盘底部,并避开圆角裁切区域。 + /// + /// 仅留约半行安全距离,避免提示紧贴下沿;提示位置独立于概览内容高度。 + static func staleScheduleNoticeBottomInset(for height: CGFloat) -> CGFloat { + max(14, height * 0.055) + } + + /// 顶部安全距离由各滚动内容内部负责;根容器保持全屏。 + static let contentTopInset: CGFloat = 0 + + static func edgeInset(for size: CGSize) -> CGFloat { + max(2, min(size.width, size.height) * 0.02) + } + + static func refreshTopInset(for height: CGFloat) -> CGFloat { + max(20, height * 0.21) + } + + static func modeBottomInset(for height: CGFloat) -> CGFloat { + max(10, height * 0.08) + } + + /// 刷新按钮与 `floatingControlsLayer` 完全相同的响应式中心点。 + static func refreshControlCenter(in size: CGSize) -> CGPoint { + let radius = controlVisualDiameter * 0.5 + return CGPoint( + x: size.width - edgeInset(for: size) - radius, + y: refreshTopInset(for: size.height) + radius + ) + } + + /// 视图切换按钮与 `floatingControlsLayer` 完全相同的响应式中心点。 + static func modeControlCenter(in size: CGSize) -> CGPoint { + let radius = controlVisualDiameter * 0.5 + return CGPoint( + x: size.width - edgeInset(for: size) - radius, + y: size.height - modeBottomInset(for: size.height) - radius + ) + } + +} + +private enum RootFloatingControlKind { + case refresh + case mode +} + +private enum WidgetOnboardingEntry { + case fullTutorial + case guide +} + +/// Apple Watch 课表的根容器。 +/// +/// 该视图负责模式切换、刷新入口、自动隐藏控件和同步完成提示;具体课程内容 +/// 由五个独立页面分别承担,避免在根页面中混入日/周/月布局细节。 +struct RootScheduleView: View { + @Environment(\.scenePhase) private var scenePhase + @EnvironmentObject private var store: WatchScheduleStore + @StateObject private var onboardingInput = WatchOnboardingInputBridge() + @State private var mode = WatchCalendarMode.overview + @State private var showsModePicker = false + @State private var showsSyncCompletion = false + @State private var dismissesStaleScheduleNotice = false + @State private var showsCachedScheduleNotice = false + @State private var refreshRotation = 0.0 + @State private var controlsVisible = true + @State private var hideControlsTask: Task? + @State private var cachedScheduleNoticeTask: Task? + @State private var onboardingNoticeTask: Task? + @State private var onboardingStep: WatchOnboardingStep? + @State private var onboardingViewportSize: CGSize = .zero + @State private var onboardingViewportGlobalFrame: CGRect = .zero + @State private var refreshControlGlobalFrame: CGRect = .zero + @State private var modeControlGlobalFrame: CGRect = .zero + /// 周视图教学随机选中的真实课程。步骤内保持不变,避免提示跳到 + /// 另一个色块;重新进入引导时重新选择。 + @State private var onboardingWeekTargetCourse: WatchCourse? + /// 目标色块在整个屏幕全局坐标系中的几何中心。 + @State private var weekCourseGlobalCenter: CGPoint? + @State private var detailCloseGlobalFrame: CGRect = .zero + @State private var widgetOnboardingEntry: WidgetOnboardingEntry? + + private var showsWidgetOnboarding: Bool { widgetOnboardingEntry != nil } + + /// 分段纯黑提示页等待用户轻点继续,不占用实操教学步骤。 + @State private var onboardingSectionIntro: WatchOnboardingSection? + @State private var onboardingSectionIntroTask: Task? + /// App 启动后立即合作式预热课程列表、日索引与月历窗口。 + @State private var onboardingRenderPreparationTask: Task? + @State private var onboardingRenderDataReady = false + /// 第一段黑场和首个实操提示已经在欢迎页背后完成首轮构造。 + @State private var onboardingInitialPresentationReady = false + @State private var onboardingSectionPreparation = + WatchOnboardingPreparationState.ready + /// 自定义分页器按最后一个表冠刻度防抖,停止后才提交教学判断。 + @State private var onboardingCrownEvaluationTask: Task? + @State private var onboardingTeachingDate: Date? + /// 错误操作后递增,用来重建当前教学页面并恢复到步骤开始状态。 + @State private var onboardingPageResetToken = 0 + /// 长按完成后 watchOS 可能补发一次普通 Button 点击;只抑制这一笔。 + @State private var suppressesModeTapAfterLongPress = false + @State private var modeButtonPress = WatchPressSession() + @GestureState private var modeButtonGestureIsActive = false + @State private var modeButtonLongPressTask: Task? + @State private var modeButtonHoldFeedback: WatchHoldFeedbackPulse? + @State private var modeButtonTapReleaseTask: Task? + @State private var syncCompletionTask: Task? + @State private var showsOnboardingNotice = false + /// 首次教学必须使用真实课程示范;无课表时停在教学外。 + @State private var onboardingWaitsForSchedule = false + @State private var showsOnboardingScheduleAlert = false + @State private var didCheckOnboarding = false + @State private var selectedCourse: WatchCourse? + @State private var overviewOpenRevision = 0 + @State private var daySelectedDate = Calendar.current.startOfDay( + for: Date() + ) + @State private var showsDayDatePicker = false + @State private var dayDatePickerInitialDate = Calendar.current.startOfDay( + for: Date() + ) + /// 首帧后短暂预挂载真实月视图,提前建立 NavigationStack、分页器、 + /// Canvas 和工具栏的渲染管线;该实例不可见且不接收任何输入。 + @State private var prewarmsMonthPresentation = false + @State private var didPrewarmMonthPresentation = false + @State private var monthPresentationPrewarmTask: Task? + private let connectivity = WatchConnectivityManager.shared + + /// 手机同步期间两个入口必须保持可见,不受滚动和自动隐藏计时影响。 + private var controlsShouldBeVisible: Bool { + guard !showsWidgetOnboarding else { return false } + return controlsVisible + || store.isRefreshing + || store.isAwaitingLaunchSyncReply + || onboardingForcesControlsVisible + || showsOnboardingNotice + } + + /// 引导通常保持操作入口可见;只有“点击空白隐藏/显示”两步需要展示 + /// 真实的切换结果,因此暂时服从 `controlsVisible`。 + private var onboardingForcesControlsVisible: Bool { + guard let onboardingStep else { return false } + return !onboardingStep.teachesControlVisibility + } + + /// 日视图日期入口和模式目录共用同一个全屏月份承载层。 + /// + /// 两种入口使用相同安全区与可用尺寸,月份网格不会因入口不同而缩放 + /// 或位移。 + private var presentsFullScreenMonthPage: Bool { + if showsDayDatePicker { + return true + } + guard mode == .month else { return false } + // 新手引导需要展示真实月历,即使当前还没有任何课表缓存。 + // MonthScheduleView 的日期网格本身不依赖课程,空课表只是不绘制标记。 + if let onboardingStep, + onboardingStep.requiredMode == .month + { + return true + } + guard store.snapshot != nil else { return false } + return !store.launchSyncTimedOut || store.hasCachedScheduleContent + } + + var body: some View { + NavigationStack { + ZStack { + GeometryReader { proxy in + let edgeInset = RootScheduleLayout.edgeInset( + for: proxy.size + ) + let topInset = RootScheduleLayout.contentTopInset + let viewportGlobalFrame = proxy.frame(in: .global) + + ZStack { + // 课表主体铺满表盘。概览、课程列表和空状态没有独立 + // 点击层,由这里统一切换悬浮按钮;日、周视图各自有 + // 与分页/课程命中协调过的单一点击入口,避免一次点击 + // 被父子两层重复处理。 + content + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .top + ) + .padding(.horizontal, edgeInset) + .padding(.top, topInset) + .padding(.bottom, edgeInset) + .contentShape(Rectangle()) + // 概览和课程列表需要同时支持“轻点空白切换按钮”与 + // 原生纵向滚动。这里不能使用 simultaneousGesture: + // 实体表上短内容的橡皮筋拖动可能与 TapGesture 同时 + // 成功,轻点会先被教学判为错误,随后正确的纵向滚动 + // 又因反馈状态而被忽略。普通点击手势会在 ScrollView + // 开始跟随手指后自动失败,因此两种输入保持互斥。 + .onTapGesture { + guard mode == .overview + || mode == .courseList + else { return } + toggleControlsFromContentTap() + } + + // 提示不截获手势,显示期间仍可滚动或点击课程。 + if showsSyncCompletion { + syncCompletionToast + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .bottom + ) + .padding( + .bottom, + RootScheduleLayout.completionBottomInset + ) + .allowsHitTesting(false) + .transition( + .opacity.combined(with: .scale(scale: 0.84)) + ) + } + + // 启动请求超时但本机仍有缓存时,只显示紧凑提示。 + if showsCachedScheduleNotice, + store.hasCachedScheduleContent, + !showsOnboardingNotice + { + cachedScheduleNotice + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .bottom + ) + .padding(.horizontal, edgeInset + 6) + .padding( + .bottom, + RootScheduleLayout + .cachedScheduleNoticeBottomInset + ) + .transition( + .opacity.combined(with: .scale(scale: 0.92)) + ) + .zIndex(90) + } + + // 过期提示使用整块表盘作为坐标系,固定在底部安全区 + // 上方,不参与概览课程内容的纵向排版。 + if mode == .overview, + store.isStale, + !dismissesStaleScheduleNotice + { + staleScheduleNotice + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .bottom + ) + .padding(.horizontal, edgeInset + 6) + .padding( + .bottom, + RootScheduleLayout + .staleScheduleNoticeBottomInset( + for: proxy.size.height + ) + ) + .zIndex(80) + } + + // 首次引导完成后的说明复用缓存提示的紧凑玻璃形态。 + // 它可单击关闭,并在 15 秒后自动退出,不阻塞课表操作。 + if showsOnboardingNotice { + onboardingCompletionNotice + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .bottom + ) + .padding(.horizontal, edgeInset + 6) + .padding( + .bottom, + RootScheduleLayout.onboardingNoticeBottomInset + ) + .transition( + .opacity.combined(with: .scale(scale: 0.92)) + ) + .zIndex(95) + } + + courseDetailOverlay + } + .onAppear { + onboardingViewportSize = proxy.size + onboardingViewportGlobalFrame = viewportGlobalFrame + } + .onChange(of: proxy.size) { _, size in + onboardingViewportSize = size + } + .onChange(of: viewportGlobalFrame) { _, frame in + onboardingViewportGlobalFrame = frame + } + .animation( + .easeInOut(duration: 0.22), + value: store.launchSyncTimedOut + ) + } + .ignoresSafeArea() + .allowsHitTesting(!presentsFullScreenMonthPage && !showsWidgetOnboarding) + // `allowsHitTesting` 只阻止触摸,底层日视图仍可能留在 + // watchOS 焦点树中并继续接收表冠。选择器展示期间同时禁用 + // 整个底层页面,确保 Digital Crown 只路由到顶层选择器。 + .disabled(presentsFullScreenMonthPage || showsWidgetOnboarding) + .accessibilityHidden(presentsFullScreenMonthPage || showsWidgetOnboarding) + + // 日期选择器和顶层月视图共用根容器中的独立全屏页面,保证 + // 两种入口具有完全相同的尺寸、安全区、星期栏和分页行为。 + if presentsFullScreenMonthPage { + MonthScheduleView( + initialDate: presentedMonthInitialDate, + initialWindow: store.preparedMonthCalendarWindow( + centeredOn: presentedMonthInitialDate + ), + submit: submitPresentedMonthDate, + cancel: dismissPresentedMonthPage, + onEmptyTap: toggleControlsFromContentTap, + onCrownInput: handleOnboardingCrownInput, + onTouchInputBegan: handleOnboardingTouchInputBegan, + onSwipeInput: handleOnboardingSwipeInput, + onHeaderPreviousTap: handleOnboardingHeaderPreviousTap, + onHeaderNextTap: handleOnboardingHeaderNextTap + ) + // 教学的每个月视图步骤都从选定的有课日期出发; + // 正常使用时 identity 稳定,不会破坏用户当前浏览月。 + .id( + onboardingStep == nil + ? "month-page" + : "onboarding-month-\(onboardingPageResetToken)" + ) + .transition(.move(edge: .bottom)) + .zIndex(200) + } + + // 数据窗口已由 Store 初始化完成;这里利用首帧后的空闲窗口 + // 预构造一次真实月份页面。0.001 透明度会让渲染器实际建立 + // Canvas 管线,同时肉眼不可见,也不会抢占触摸、表冠或辅助 + // 功能焦点。首次真正打开月视图时只需显示已热身的组件类型。 + if prewarmsMonthPresentation, + !presentsFullScreenMonthPage + { + MonthScheduleView( + initialDate: daySelectedDate, + initialWindow: store.preparedMonthCalendarWindow( + centeredOn: daySelectedDate + ), + submit: { _ in }, + cancel: {}, + onEmptyTap: {}, + onCrownInput: {}, + onTouchInputBegan: {}, + onSwipeInput: { _ in }, + onHeaderPreviousTap: {}, + onHeaderNextTap: {}, + prewarmingOnly: true + ) + .opacity(0.001) + .allowsHitTesting(false) + .accessibilityHidden(true) + .zIndex(-100) + } + + // 悬浮入口必须位于独立月视图之上,否则月视图虽然能够改变 + // 可见状态,按钮仍会被黑色全屏页面盖住。详情页继续独占最 + // 上层,因此打开课程详情时不显示这两个入口。 + if selectedCourse == nil, !showsModePicker { + GeometryReader { proxy in + floatingControlsLayer(size: proxy.size) + } + .ignoresSafeArea() + .zIndex(300) + } + + // 最后加入根 ZStack,确保说明遮罩能覆盖所有真实页面。 + // 进入实操后遮罩完全隐去,输入仍由底层原生页面接收。 + if onboardingStep != nil || showsWidgetOnboarding { + onboardingOverlayLayer + .transition(.opacity) + // 教学层必须高于详情页、月视图和两个悬浮按钮;内部再由 + // Overlay 自己区分说明面板、动作提示和结果反馈的层级。 + .zIndex(10_000) + } + } + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + } + .sheet(isPresented: $showsModePicker) { + modePicker + // 系统 Sheet 位于根视图的 zIndex 体系之外;在同一个展示层 + // 复用教学 Overlay,才能确保进度、提示与结果始终高于目录。 + .overlay { + if onboardingStep != nil { + onboardingOverlayLayer + .transition(.opacity) + .zIndex(10_000) + } + } + } + .alert( + Text(verbatim: watchLocalizedString("请先同步课表")), + isPresented: $showsOnboardingScheduleAlert + ) { + Button(role: .cancel) { + // 只关闭提示,保留等待状态;手机送达课表后 + // 会自动继续进入欢迎页。 + } label: { + Text(verbatim: watchLocalizedString("好")) + } + } message: { + Text(verbatim: watchLocalizedString( + "请打开手机 XDYou 并单击刷新按钮" + )) + } + .onAppear(perform: handleAppear) + .onDisappear(perform: handleDisappear) + .onChange(of: scenePhase) { _, phase in + guard phase != .active else { return } + cancelModeButtonPress() + resetModeButtonPressState() + } + .onOpenURL(perform: openWidgetDestination) + .onChange(of: store.isRefreshing) { _, isRefreshing in + handleRefreshStateChange(isRefreshing) + } + .onChange(of: store.isAwaitingLaunchSyncReply) { _, isAwaiting in + handleLaunchReplyWaitChange(isAwaiting) + } + .onChange(of: store.launchSyncTimedOut) { _, didTimeOut in + updateCachedScheduleNotice(didTimeOut: didTimeOut) + } + .onChange(of: store.completedRefreshCount) { _, count in + guard count > 0 else { return } + showCompletion(for: count) + } + .onChange(of: store.isStale) { wasStale, isStale in + guard wasStale != isStale else { return } + dismissesStaleScheduleNotice = false + } + .onChange(of: store.renderCacheRevision) { _, _ in + handleScheduleReplacement() + } + .onChange(of: modeButtonGestureIsActive) { _, isActive in + // 系统取消手势时不会调用 onEnded;此时只清理,绝不补发单击。 + if !isActive, modeButtonPress.isActive || modeButtonPress.isCancelled { + cancelModeButtonPress() + resetModeButtonPressState() + } + } + .onChange(of: daySelectedDate) { _, date in + // 用户浏览日视图时同步准备对应月份;以后从标题进入月视图不会 + // 把月份模型计算推迟到点击发生的那一帧。 + store.prewarmMonthCalendar(around: date) + } + .onChange(of: mode) { _, _ in + dismissCourseDetailImmediately() + dismissDayDatePickerImmediately() + } + } + + /// 初始化只与根页面生命周期相关的视觉状态。 + /// + /// 数据请求由 `TraintimeWatchApp` 和 `WatchConnectivityManager` 负责;根 + /// 页面只读取 Store 状态,避免视图重建时重复创建同步任务。 + private func handleAppear() { + updateRefreshAnimation(isRefreshing: store.isRefreshing) + revealControls() + updateCachedScheduleNotice(didTimeOut: store.launchSyncTimedOut) + scheduleMonthPresentationPrewarm() + scheduleOnboardingIfNeeded() + } + + /// 从任意小组件回到概览顶部;不把本次跳过教学记为“已完成”。 + private func openWidgetDestination(_ url: URL) { + guard WatchWidgetDestination(url: url) != nil else { return } + resetModeButtonPressState() + cancelOnboardingTasks() + onboardingInput.clear() + resetOnboardingTargets() + onboardingStep = nil + widgetOnboardingEntry = nil + onboardingWaitsForSchedule = false + showsOnboardingScheduleAlert = false + showsOnboardingNotice = false + didCheckOnboarding = true + showsModePicker = false + dismissCourseDetailImmediately() + dismissDayDatePickerImmediately() + mode = .overview + overviewOpenRevision &+= 1 + revealControls() + } + + /// 页面离开时取消只服务于界面的延迟任务。 + private func handleDisappear() { + cancelFloatingControlTasks() + cancelOnboardingTasks() + resetOnboardingTargets() + resetModeButtonPressState() + onboardingInput.clear() + cancelMonthPresentationPrewarm() + } + + /// 取消悬浮控件与非阻塞提示使用的延迟任务。 + /// + /// 每个引用都在取消后立即置空,后续 `onAppear` 可以准确判断是否需要 + /// 重新创建任务,也不会把已经完成的 Task 当成仍在运行。 + private func cancelFloatingControlTasks() { + syncCompletionTask?.cancel() + syncCompletionTask = nil + showsSyncCompletion = false + hideControlsTask?.cancel() + hideControlsTask = nil + cachedScheduleNoticeTask?.cancel() + cachedScheduleNoticeTask = nil + onboardingNoticeTask?.cancel() + onboardingNoticeTask = nil + } + + /// 取消新手引导的计时、章节过渡和表冠停止判定。 + /// + /// 页面销毁、整轮引导重新开始或完成时调用,防止旧步骤的异步回调 + /// 修改新页面状态。 + private func cancelOnboardingTasks() { + onboardingSectionIntroTask?.cancel() + onboardingSectionIntroTask = nil + onboardingSectionIntro = nil + onboardingSectionPreparation = .ready + onboardingCrownEvaluationTask?.cancel() + onboardingCrownEvaluationTask = nil + + onboardingRenderPreparationTask?.cancel() + onboardingRenderPreparationTask = nil + onboardingRenderDataReady = false + onboardingInitialPresentationReady = false + } + + /// 清空仅对当前引导示例有效的课程与坐标。 + private func resetOnboardingTargets() { + onboardingWeekTargetCourse = nil + weekCourseGlobalCenter = nil + detailCloseGlobalFrame = .zero + } + + /// 恢复模式按钮的空闲状态,不触发单击或长按结果。 + private func resetModeButtonPressState() { + cancelModeButtonHoldFeedback() + modeButtonTapReleaseTask?.cancel() + modeButtonTapReleaseTask = nil + modeButtonPress.reset() + suppressesModeTapAfterLongPress = false + } + + /// 结束不可见月视图的渲染预热。 + private func cancelMonthPresentationPrewarm() { + monthPresentationPrewarmTask?.cancel() + monthPresentationPrewarmTask = nil + prewarmsMonthPresentation = false + } + + /// 首个可见帧提交后预热一次月份页面的真实 SwiftUI 渲染树。 + /// + /// Store 初始化负责数据和三页窗口,这里只处理必须依赖显示环境的首次 + /// View/Canvas/Toolbar 构造。预热实例保留约 0.8 秒,足够异步 Canvas + /// 完成首轮绘制;之后立即移除,不持续占用后续交互帧。 + private func scheduleMonthPresentationPrewarm() { + guard !didPrewarmMonthPresentation, + monthPresentationPrewarmTask == nil + else { return } + monthPresentationPrewarmTask = Task { @MainActor in + await Task.yield() + guard !Task.isCancelled else { return } + prewarmsMonthPresentation = true + try? await Task.sleep(nanoseconds: 800_000_000) + guard !Task.isCancelled else { return } + prewarmsMonthPresentation = false + didPrewarmMonthPresentation = true + monthPresentationPrewarmTask = nil + } + } + + /// 从日视图进入独立日期选择页;页面从表盘底部向上弹入。 + private func presentDayDatePicker(_ date: Date) { + guard mode == .day, !showsDayDatePicker else { return } + reportOnboardingOperation( + .tap(.headerTitle), + target: .headerTitle + ) + dayDatePickerInitialDate = Calendar.current.startOfDay(for: date) + hideControls() + withAnimation(monthScheduleTransitionAnimation) { + showsDayDatePicker = true + } + } + + /// 日期格被点中时才提交选择;随后沿进入路径退回底部。 + private func submitDayDatePicker(_ date: Date) { + daySelectedDate = Calendar.current.startOfDay(for: date) + WatchHaptics.selection() + dismissDayDatePicker() + } + + /// 点击月份标题退出,不修改 `daySelectedDate`。 + private func dismissDayDatePicker() { + guard showsDayDatePicker else { return } + withAnimation(monthScheduleTransitionAnimation) { + showsDayDatePicker = false + } + hideControls() + } + + /// 模式变化不播放迟到的选择器动画,直接清理独立页面路由。 + private func dismissDayDatePickerImmediately() { + showsDayDatePicker = false + } + + /// 根据入口提供初始日期,但不让月视图承担日视图的任何业务状态。 + private var presentedMonthInitialDate: Date { + showsDayDatePicker ? dayDatePickerInitialDate : daySelectedDate + } + + /// 全屏月份页只负责选择日期;最终提交路径由打开它的入口决定。 + private func submitPresentedMonthDate(_ date: Date) { + reportOnboardingOperation( + .tap(.calendarDate), + target: .calendarDate + ) + if showsDayDatePicker { + submitDayDatePicker(date) + } else { + submitMonthViewDate(date) + } + } + + /// 日期入口关闭后留在日视图;顶层月视图关闭后切回日视图。 + private func dismissPresentedMonthPage() { + reportOnboardingOperation( + .tap(.monthTitle), + target: .monthTitle + ) + if showsDayDatePicker { + dismissDayDatePicker() + } else { + dismissMonthView() + } + } + + /// 将刷新状态映射为旋转动画和悬浮控件可见性。 + private func handleRefreshStateChange(_ isRefreshing: Bool) { + updateRefreshAnimation(isRefreshing: isRefreshing) + if isRefreshing { + keepControlsVisibleDuringRefresh() + } else { + revealControls() + } + } + + /// 启动同步等待期间保持操作入口可见;收到回复后恢复自动隐藏计时。 + private func handleLaunchReplyWaitChange(_ isAwaiting: Bool) { + if isAwaiting { + keepControlsVisibleDuringRefresh() + } else if !store.isRefreshing { + revealControls() + } + } + + /// 根据当前模式选择内容,并统一传入表冠交互回调。 + @ViewBuilder + private var content: some View { + if onboardingWaitsForSchedule + || (store.launchSyncTimedOut + && !store.hasCachedScheduleContent + ) + { + openPhoneSyncState + } else if store.snapshot == nil { + emptyState + } else { + switch mode { + case .overview: + OverviewScheduleView( + onCrownInteraction: handlePassiveScrollInteraction, + onCrownInput: handleOnboardingCrownInput, + onTouchInput: handleOnboardingVerticalSwipeInput, + alwaysAllowsTeachingBounce: + onboardingStep == .overviewSwipe + || onboardingStep == .overviewCrown, + drivesTeachingTouchScroll: + onboardingStep == .overviewSwipe, + inputContext: onboardingStep?.rawValue ?? -1 + ) + .id( + onboardingStep == nil + ? "overview-page-\(overviewOpenRevision)" + : "onboarding-overview-\(onboardingPageResetToken)" + ) + case .courseList: + CourseListView( + onCrownInteraction: handlePassiveScrollInteraction, + onCrownInput: handleOnboardingCrownInput, + onTouchInput: handleOnboardingVerticalSwipeInput, + alwaysAllowsTeachingBounce: + onboardingStep == .courseListSwipe + || onboardingStep == .courseListCrown, + drivesTeachingTouchScroll: + onboardingStep == .courseListSwipe, + inputContext: onboardingStep?.rawValue ?? -1, + positionsInitialDate: onboardingStep == nil + ) + .id( + onboardingStep == nil + ? "course-list-page" + : "onboarding-list-\(onboardingPageResetToken)" + ) + case .day: + DayScheduleView( + selectedDate: $daySelectedDate, + isDatePickerPresented: showsDayDatePicker, + onDatePickerRequested: presentDayDatePicker, + onContentTap: toggleControlsFromContentTap, + onCrownInteraction: hideControls, + onCrownInput: handleOnboardingCrownInput, + onCrownPageInput: handleOnboardingDayCrownPageInput, + onTouchInputBegan: handleOnboardingTouchInputBegan, + onSwipeInput: handleOnboardingSwipeInput, + onHeaderPreviousTap: handleOnboardingHeaderPreviousTap, + onHeaderNextTap: handleOnboardingHeaderNextTap + ) + .id( + onboardingStep == nil + ? "day-page" + : "onboarding-day-\(onboardingPageResetToken)" + ) + case .month: + // 月视图实际内容由根层的全屏页面承载。这里仅提供不会参与 + // 布局的背景,避免再次把月份页嵌进主体缩进容器。 + Color.black + case .week: + WeekScheduleView( + selectedCourse: $selectedCourse, + // 正常进入仍从本周开始;只有教学期间才改用 + // 预先选定的真实有课日期。 + initialDate: onboardingTeachingDate ?? Date(), + onEmptyTap: toggleControlsFromContentTap, + onCrownInteraction: hideControls, + onCrownInput: handleOnboardingCrownInput, + onTouchInputBegan: handleOnboardingTouchInputBegan, + onSwipeInput: handleOnboardingSwipeInput, + onHeaderPreviousTap: handleOnboardingHeaderPreviousTap, + onHeaderNextTap: handleOnboardingHeaderNextTap, + onboardingTargetCourse: onboardingWeekTargetCourse, + onCourseFrameChange: recordOnboardingWeekCourseFrame, + onCourseSelected: { course in + reportOnboardingWeekCourseSelection(course) + } + ) + .id( + onboardingStep == nil + ? "week-page" + : "onboarding-week-\(onboardingPageResetToken)" + ) + } + } + } + + /// 月视图选择日期后提交给日视图,并保持既有选中反馈。 + private func submitMonthViewDate(_ date: Date) { + daySelectedDate = Calendar.current.startOfDay(for: date) + WatchHaptics.selection() + withAnimation(monthScheduleTransitionAnimation) { + mode = .day + } + hideControls() + } + + /// 点击月视图的月份标题时返回日视图,不改变当前日期。 + private func dismissMonthView() { + withAnimation(monthScheduleTransitionAnimation) { + mode = .day + } + hideControls() + } + + /// 详情页从底部出现、关闭时回到底部的统一动画。 + private var detailAnimation: Animation { + .spring(response: 0.38, dampingFraction: 0.84) + } + + /// 根容器最上层的课程详情。 + /// + /// 不使用系统 Sheet 是刻意设计:watchOS 会为 Sheet 强制增加左上角 + /// 关闭按钮,且该按钮无法与详情中固定在右侧的关闭入口合并。把详情 + /// 放在根 ZStack 最后一层,既能保证它覆盖其他控件,也能在清空 + /// `selectedCourse` 时同步、确定地解除整个详情视图。 + @ViewBuilder + private var courseDetailOverlay: some View { + if let selectedCourse { + CourseDetailView( + course: selectedCourse, + showsTopCloseButton: true, + onScroll: handlePassiveScrollInteraction, + onCrownInput: handleOnboardingCrownInput, + onTouchInput: handleOnboardingVerticalSwipeInput, + onCloseButtonFrameChange: { + recordOnboardingTargetFrame(.detailClose, frame: $0) + }, + dismiss: dismissCourseDetail + ) + // 教学从手指滑动进入表冠步骤时重建原生 ScrollView, + // 阻止上一步惯性滚动被误判成新的表冠操作。 + .id( + onboardingStep == nil + ? "course-detail" + : "onboarding-detail-\(onboardingPageResetToken)" + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .transition(.move(edge: .bottom)) + .zIndex(100) + } + } + + /// 使用与出现时相同的弹簧动画原子移除详情。 + private func dismissCourseDetail() { + // 关闭按钮已经做了单次触发保护,这里再次校验可让辅助功能、模式 + // 切换或迟到的主线程任务安全地重复调用,而不会启动第二段转场。 + guard selectedCourse != nil else { return } + reportOnboardingOperation( + .tap(.detailClose), + target: .detailClose + ) + withAnimation(detailAnimation) { + selectedCourse = nil + } + } + + /// 模式切换不需要退出动画,直接清理不再属于当前页面的详情状态。 + private func dismissCourseDetailImmediately() { + selectedCourse = nil + } + + /// 完全没有缓存时显示;离线但有缓存时仍会展示课表。 + private var emptyState: some View { + InteractionAwareScrollView( + onScroll: hideControls, + centersShortContent: true, + protectsInitialTopEdge: true + ) { + ContentUnavailableView { + Label("暂无课表", systemImage: "iphone.and.arrow.forward") + } description: { + Text( + store.isAwaitingLaunchSyncReply + ? watchLocalizedString("正在从手机同步课表") + : store.syncError + ?? watchLocalizedString( + "请在配对的 iPhone 上打开应用并刷新课表" + ) + ) + } + .padding(.horizontal, 4) + } + } + + /// 本机完全没有可展示缓存且启动请求超时时显示的整页引导。 + private var openPhoneSyncState: some View { + InteractionAwareScrollView( + onScroll: hideControls, + centersShortContent: true, + protectsInitialTopEdge: true + ) { + ContentUnavailableView( + "请打开手机 XDYou 并单击刷新按钮", + systemImage: "iphone.and.arrow.forward" + ) + .padding(.horizontal, 4) + } + } + + /// watchOS 26 使用液态玻璃,旧系统回退到标准描边样式。 + @ViewBuilder + private var refreshControl: some View { + if #available(watchOS 26.0, *) { + refreshButton + .buttonStyle(.glass) + } else { + refreshButton + .buttonStyle(.bordered) + } + } + + /// 触发强制渐进刷新;图标转动由 Store 的刷新状态驱动。 + private var refreshButton: some View { + Button { + reportOnboardingOperation(.tap(.refresh), target: .refresh) + WatchHaptics.refreshStarted() + revealControls() + connectivity.beginLaunchRefresh() + } label: { + // 先把对称图标放进固定正方形,再旋转整个正方形。若先旋转 + // SF Symbol 本身,其不对称的字形边界会造成箭头绕偏心点打转。 + Image(systemName: "arrow.triangle.2.circlepath") + .font(.caption.weight(.semibold)) + .frame( + width: RootScheduleLayout.controlContentSize, + height: RootScheduleLayout.controlContentSize + ) + .rotationEffect( + .degrees(refreshRotation), + anchor: .center + ) + } + .controlSize(.small) + .buttonBorderShape(.circle) + .fixedSize() + .opacity(controlsShouldBeVisible ? 1 : 0) + .scaleEffect(controlsShouldBeVisible ? 1 : 0.82) + .allowsHitTesting(controlsShouldBeVisible) + .animation( + .easeOut(duration: 0.2), + value: controlsShouldBeVisible + ) + .accessibilityLabel( + store.isRefreshing + ? watchLocalizedString("正在刷新") + : watchLocalizedString("从手机刷新") + ) + } + + /// 视图切换按钮的系统版本适配。 + @ViewBuilder + private var modeControl: some View { + if #available(watchOS 26.0, *) { + modeButton + .buttonStyle(.glass) + } else { + modeButton + .buttonStyle(.bordered) + } + } + + /// 打开模式选择 Sheet,不在主页面堆叠菜单内容。 + private var modeButton: some View { + // Button 本身只保留系统玻璃和按压外观;单击/三秒长按由同一个 + // 零距离手势在松手或计时到点时唯一提交,避免实体表上两套识别竞争。 + Button(action: {}) { + Image(systemName: "ellipsis") + .font(.caption.weight(.bold)) + .frame( + width: RootScheduleLayout.controlContentSize, + height: RootScheduleLayout.controlContentSize + ) + } + .controlSize(.small) + .buttonBorderShape(.circle) + .fixedSize() + .opacity(controlsShouldBeVisible ? 1 : 0) + .scaleEffect(controlsShouldBeVisible ? 1 : 0.82) + .allowsHitTesting(controlsShouldBeVisible) + .animation( + .easeOut(duration: 0.2), + value: controlsShouldBeVisible + ) + .simultaneousGesture(modeButtonPressGesture) + .sensoryFeedback(trigger: modeButtonHoldFeedback) { _, pulse in + guard scenePhase == .active, modeButtonPress.isActive, + !suppressesModeTapAfterLongPress, !showsWidgetOnboarding, + onboardingStep == nil || onboardingInput.acceptsOperations + else { return nil } + return pulse?.feedback + } + .accessibilityAction { + performModeButtonTap() + } + .accessibilityAction(named: Text(verbatim: watchLocalizedString("重新打开引导"))) { + restartOnboarding() + } + .accessibilityLabel(watchLocalizedString("切换课表视图")) + .accessibilityHint(watchLocalizedString("长按重新进入新手引导")) + } + + /// 教学先验证单击要求,错误时恢复当前步骤并显示反馈,不打开目录。 + private func performModeButtonTap() { + guard !suppressesModeTapAfterLongPress, !showsWidgetOnboarding else { return } + if let onboardingStep { + guard onboardingInput.acceptsOperations else { return } + guard onboardingStep.operation == .tap(.mode) else { + reportOnboardingOperation(.tap(.mode), target: .mode) + return + } + } + WatchHaptics.selection() + revealControls() + showsModePicker = true + reportOnboardingOperation(.tap(.mode), target: .mode) + } + + /// 选中模式后立即关闭列表;课表数据和缓存不会被重置。 + private var modePicker: some View { + NavigationStack { + List { + Section { + ForEach(WatchCalendarMode.allCases) { candidate in + Button { + selectMode(candidate) + } label: { + HStack { + Label( + candidate.title, + systemImage: candidate.systemImage + ) + Spacer() + if candidate == mode { + Image(systemName: "checkmark") + .foregroundStyle(.tint) + } + } + } + .buttonStyle(.plain) + } + } header: { + Text(verbatim: watchLocalizedString("切换视图")) + } + Section { + Button(action: requestOnboardingStart) { + Label(watchLocalizedString("App 操作教程"), systemImage: "hand.draw") + } + Button { presentWidgetOnboarding() } label: { + Label(watchLocalizedString("小组件使用指南"), systemImage: "applewatch") + } + } header: { + Text(verbatim: watchLocalizedString("使用指南")) + } + .disabled(onboardingStep != nil) + } + } + } + + /// 根页面与系统模式 Sheet 共用唯一的教学显示状态。 + /// + /// Sheet 会创建独立展示层,单纯提高根视图的 `zIndex` 无法盖住它;将 + /// 同一 Overlay 挂入两个宿主可保持画面层级一致,又不会复制步骤状态机。 + @ViewBuilder + private var onboardingOverlayLayer: some View { + if showsWidgetOnboarding { + WidgetOnboardingView(finish: finishOnboarding) + } else if let onboardingStep { + WatchOnboardingOverlay( + step: onboardingStep, + sectionIntro: onboardingSectionIntro, + sectionPreparation: onboardingSectionPreparation, + controlCenters: onboardingControlCenters, + feedback: onboardingInput.feedback, + showsPrompt: onboardingInput.showsPrompt, + isInitialPreparationReady: + onboardingRenderDataReady + && onboardingInitialPresentationReady, + initialPresentationPrepared: { + onboardingInitialPresentationReady = true + }, + start: handleOnboardingWelcomeTap, + openWidgetTutorial: handleOnboardingWelcomeHold, + continueSectionIntro: handleOnboardingSectionIntroTap + ) + } + } + + /// 提交模式选择并关闭目录。 + /// + /// 模式选择的触觉、状态提交和目录关闭必须属于同一次操作;集中在这里后, + /// 新增视图不会遗漏其中一步,也不会触碰各视图已经保存的浏览位置。 + private func selectMode(_ candidate: WatchCalendarMode) { + WatchHaptics.selection() + if candidate.hidesFloatingControlsOnEntry { + hideControls() + } + if candidate == .month { + withAnimation(monthScheduleTransitionAnimation) { + mode = candidate + } + } else { + mode = candidate + } + showsModePicker = false + } + + // MARK: - 新手引导 + + /// 首次启动仅检查一次持久化标记。 + /// + /// 欢迎页必须在首个可见帧立即出现,不能为根页面预留人为 + /// 等待时间。后续的日期定位、列表索引和月历缓存全部在欢迎 + /// 页背后准备,并由欢迎页自己展示加载状态。 + private func scheduleOnboardingIfNeeded() { + guard !didCheckOnboarding else { return } + didCheckOnboarding = true + guard !UserDefaults.standard.bool( + forKey: WatchPersistentCacheKey.completedOnboarding + ) else { return } + + requestOnboardingStart() + } + + /// 长按右下角按钮重新开始时使用同一入口,不重建课表 Store。 + /// 教学进行中只把三秒按压作为教学操作上报,绝不会递归重启引导。 + private func restartOnboarding() { + guard !showsWidgetOnboarding else { return } + if onboardingStep != nil { + reportOnboardingOperation(.longPress(.mode), target: .mode) + return + } + WatchHaptics.onboardingSuccess() + requestOnboardingStart() + } + + /// 教学需要用真实课程展示列表、日视图卡片和周视图色块。 + /// 无任何日程时不创建欢迎页,保留在手机同步页面并给出一次 + /// 明确提示。课表到达后由 `handleScheduleReplacement` + /// 自动续上,用户不需要再次长按。 + private func requestOnboardingStart() { + guard store.recommendedOnboardingDate != nil else { + onboardingWaitsForSchedule = true + showsModePicker = false + mode = .overview + withAnimation(.easeInOut(duration: 0.18)) { + showsOnboardingScheduleAlert = true + } + return + } + + onboardingWaitsForSchedule = false + showsOnboardingScheduleAlert = false + startOnboarding() + } + + /// 无课表阻断期间,手机每完成一个同步阶段都可能安装新快照。 + /// 第一条可用日程一出现就撤下弹窗,再进入正常的欢迎与预热流程。 + private func handleScheduleReplacement() { + // 数据清空或课程被删除时,详情与教学不能继续引用旧快照中的课程。 + if let selectedCourse { + self.selectedCourse = store.allCourses.first { $0.id == selectedCourse.id } + } + let targetBecameInvalid = onboardingWeekTargetCourse.map { target in + store.allCourses.first { $0.id == target.id } != target + } ?? false + let teachingDateBecameEmpty = onboardingTeachingDate.map { + store.courses(on: $0).isEmpty + } ?? false + if onboardingStep != nil, + !showsWidgetOnboarding, + store.recommendedOnboardingDate == nil || targetBecameInvalid || teachingDateBecameEmpty + { + cancelOnboardingTasks() + onboardingInput.clear() + resetOnboardingTargets() + onboardingStep = nil + dismissCourseDetailImmediately() + dismissDayDatePickerImmediately() + requestOnboardingStart() + } else if onboardingWaitsForSchedule, store.recommendedOnboardingDate != nil { + requestOnboardingStart() + } + } + + /// 零距离拖动负责精确记录按下和松开;按住满三秒的任务会立即触发, + /// 不需要等待手指抬起。三秒内松手则统一走普通单击逻辑。 + private var modeButtonPressGesture: some Gesture { + DragGesture(minimumDistance: 0, coordinateSpace: .local) + .updating($modeButtonGestureIsActive) { _, active, _ in active = true } + .onChanged { value in + guard hypot(value.translation.width, value.translation.height) <= 36 + else { + cancelModeButtonPress() + return + } + beginModeButtonPressIfNeeded() + } + .onEnded { _ in + finishModeButtonPress() + } + } + + /// 首个触摸刻度立即暂停按钮自动隐藏,并启动独立三秒计时。 + private func beginModeButtonPressIfNeeded() { + guard scenePhase == .active, + !showsWidgetOnboarding, + onboardingStep == nil || onboardingInput.acceptsOperations + else { return } + guard modeButtonPress.begin() else { return } + modeButtonTapReleaseTask?.cancel() + modeButtonTapReleaseTask = nil + suppressesModeTapAfterLongPress = false + modeButtonHoldFeedback = nil + handleModeButtonPressing(true) + handleOnboardingTouchInputBegan() + modeButtonLongPressTask?.cancel() + let initialOnboardingStep = onboardingStep + modeButtonLongPressTask = makeWatchHoldFeedbackTask( + isActive: { + scenePhase == .active + && modeButtonPress.isActive && !showsWidgetOnboarding + && onboardingStep == initialOnboardingStep + && (initialOnboardingStep == nil || onboardingInput.acceptsOperations) + }, + onPulse: { modeButtonHoldFeedback = $0 }, + onComplete: { + suppressesModeTapAfterLongPress = true + modeButtonLongPressTask = nil + modeButtonHoldFeedback = nil + restartOnboarding() + } + ) + } + + /// 松手时根据三秒任务是否已经触发,二选一执行单击或长按结果。 + private func finishModeButtonPress() { + let didTriggerLongPress = suppressesModeTapAfterLongPress + let shouldTap = modeButtonPress.finish(didTriggerLongPress: didTriggerLongPress) + cancelModeButtonHoldFeedback() + handleModeButtonPressing(false) + if shouldTap { + performModeButtonTap() + } + // 让系统 Button 可能补发的空动作先结束,再释放抑制标记。 + modeButtonTapReleaseTask?.cancel() + modeButtonTapReleaseTask = makeWatchAutoDismissTask(after: 0.18) { + suppressesModeTapAfterLongPress = false + modeButtonTapReleaseTask = nil + } + } + + private func cancelModeButtonPress() { + let wasActive = modeButtonPress.isActive + modeButtonPress.cancel() + cancelModeButtonHoldFeedback() + guard wasActive else { return } + handleModeButtonPressing(false) + } + + /// 松手、移出命中范围和页面退出都必须同时停止计时与触觉脉冲。 + private func cancelModeButtonHoldFeedback() { + modeButtonLongPressTask?.cancel() + modeButtonLongPressTask = nil + modeButtonHoldFeedback = nil + } + + /// 按住三点按钮期间暂停自动隐藏,避免长按计时与 2.6 秒隐藏任务竞争。 + private func handleModeButtonPressing(_ isPressing: Bool) { + if isPressing { + hideControlsTask?.cancel() + hideControlsTask = nil + if !controlsVisible { + withAnimation(.easeOut(duration: 0.12)) { + controlsVisible = true + } + } + } else if onboardingStep == nil { + revealControls() + } + } + + /// 清理可能盖住课表的临时页面,再从第一步进入引导。 + private func startOnboarding() { + onboardingNoticeTask?.cancel() + onboardingNoticeTask = nil + cancelOnboardingTasks() + onboardingInput.clear() + showsModePicker = false + showsOnboardingNotice = false + widgetOnboardingEntry = nil + showsDayDatePicker = false + selectedCourse = nil + showsCachedScheduleNotice = false + onboardingWaitsForSchedule = false + showsOnboardingScheduleAlert = false + onboardingTeachingDate = nil + resetOnboardingTargets() + mode = .overview + keepControlsVisibleDuringRefresh() + withAnimation(WatchOnboardingMotion.pageTransition) { + onboardingStep = .welcome + } + configureOnboardingInput(for: .welcome) + // 先把轻量欢迎页提交给渲染循环,再计算教学日期和五个页面的派生 + // 数据。真机不会在点击“重新引导”后卡在尚未出现的首帧。 + startOnboardingRenderPreparation() + } + + /// 欢迎页出现后立即预热后续五个页面共用的派生数据。 + /// + /// 已命中持久化缓存时直接标记完成;缓存缺失时把工作拆到多个可让出 + /// 执行权的阶段。课程列表章节页会在必要时等待这个任务,但欢迎、概览 + /// 动画和用户操作不会被同步阻塞。 + private func startOnboardingRenderPreparation() { + onboardingRenderPreparationTask?.cancel() + onboardingRenderDataReady = false + onboardingInitialPresentationReady = false + onboardingRenderPreparationTask = Task { @MainActor in + // 必须先让欢迎页完成一次提交;下面的日期选择和持久化缓存恢复 + // 即使命中缓存,也不占用欢迎页出现前的关键帧。 + await Task.yield() + guard !Task.isCancelled else { return } + + let resolvedDate = store.recommendedOnboardingDate + let date = resolvedDate ?? Date() + installOnboardingTeachingDate(resolvedDate) + + if store.hasPreparedOnboardingRenderData(around: date) { + onboardingRenderDataReady = true + return + } + + await store.prepareOnboardingRenderData(around: date) + guard !Task.isCancelled else { return } + // 预热返回且任务未取消时,本轮索引与月历窗口已经就绪。 + // 课表替换导致的教学数据失效由 handleScheduleReplacement 处理。 + onboardingRenderDataReady = true + } + } + + /// 前进到下一项;实操结束后衔接小组件介绍。 + private func showNextOnboardingStep() { + guard let onboardingStep else { return } + guard let next = WatchOnboardingStep( + rawValue: onboardingStep.rawValue + 1 + ) else { + showOnboardingWidgetIntroduction() + return + } + presentOnboardingStep(next) + } + + /// 原子切换步骤及其对应的真实背景页面。 + private func presentOnboardingStep(_ step: WatchOnboardingStep) { + resetOnboardingTargetFrame(for: step) + if let section = WatchOnboardingSection.starting(at: step) { + presentOnboardingSectionIntro(section, for: step) + } else { + applyOnboardingBackground(for: step) + withAnimation(WatchOnboardingMotion.pageTransition) { + onboardingStep = step + } + configureOnboardingInput(for: step) + } + } + + /// 在黑色章节页下方切换真实页面;普通步骤也复用同一原子入口。 + /// + /// 这里不启动任何视觉转场。章节页先完整覆盖表盘,下一次主线程更新才 + /// 安装底层页面,因此不会再出现“先漏一帧页面变化、随后才变黑”。 + private func applyOnboardingBackground(for step: WatchOnboardingStep) { + performWithoutAnimation { + installOnboardingRoute(for: step) + } + } + + /// 把教学示例日期原子写入日视图和月份选择器入口。 + /// + /// Store 可能尚未给出推荐日期,因此 `nil` 只清空教学引用,不改动用户 + /// 正在浏览的日期;一旦日期可用,两个入口始终保持一致。 + private func installOnboardingTeachingDate(_ date: Date?) { + onboardingTeachingDate = date + guard let date else { return } + let day = Calendar.current.startOfDay(for: date) + onboardingTeachingDate = day + daySelectedDate = day + dayDatePickerInitialDate = day + } + + /// 安装某一步需要的真实底层页面和详情路由。 + /// + /// 正常推进与错误恢复共用该入口,避免两条路径对日期选择器、详情课程或 + /// 悬浮控件的处理逐渐产生差异。调用方负责决定是否禁用动画。 + private func installOnboardingRoute(for step: WatchOnboardingStep) { + if step.requiredMode.usesSelectedDate { + installOnboardingTeachingDate( + onboardingTeachingDate ?? store.recommendedOnboardingDate + ) + } + selectedCourse = step == .courseDetailClose + ? onboardingDetailTeachingCourse + : nil + showsDayDatePicker = step.presentsDayDatePicker + mode = step.requiredMode + prepareControlVisibility(for: step) + } + + /// 为需要重新选择示例内容的步骤清理动态目标。 + /// + /// 详情页在“点开课程”成功后已经显示并上报关闭按钮坐标,进入下一步时 + /// 必须保留该坐标;若清零但不重建详情页,GeometryReader 不会再次回调, + /// 关闭提示就会一直隐藏到错误恢复重建页面之后。 + private func resetOnboardingTargetFrame(for step: WatchOnboardingStep) { + switch step.operation { + case .tap(.weekCourse): + weekCourseGlobalCenter = nil + onboardingWeekTargetCourse = randomOnboardingWeekCourse() + // 前面的箭头、滑动和表冠教学可能已把周页带离示范课程。 + // 只在进入色块教学时重建一次并回到教学周;其他相邻步骤保持 + // 相同 identity,复用周视图及其表冠状态。 + onboardingPageResetToken &+= 1 + default: + break + } + } + + /// 在每个顶层视图的第一项实操前显示纯黑分段页。 + /// + /// 分段页等待用户主动轻点;等待期间输入桥保持清空。 + /// 这样阅读速度不会影响教学节奏,轻点后的淡出也不会误算成下一项操作。 + private func presentOnboardingSectionIntro( + _ section: WatchOnboardingSection, + for step: WatchOnboardingStep + ) { + onboardingSectionIntroTask?.cancel() + onboardingInput.clear() + onboardingSectionPreparation = .ready + // 黑色覆盖层先同步插入;底层模式在下一次 run-loop 才更新。 + // `WatchOnboardingOverlay` 的非对称 transition 保证插入没有淡入漏帧。 + performWithoutAnimation { + onboardingStep = step + onboardingSectionIntro = section + } + + onboardingSectionIntroTask = Task { @MainActor in + await Task.yield() + guard !Task.isCancelled, onboardingStep == step, + onboardingSectionIntro == section + else { return } + + let waitsForInitialRender = section == .courseList + && !onboardingRenderDataReady + if waitsForInitialRender { + onboardingSectionPreparation = .loading + await onboardingRenderPreparationTask?.value + guard !Task.isCancelled, + onboardingStep == step, + onboardingSectionIntro == section + else { return } + } + + // 页面在纯黑覆盖下完成首次构造;用户阅读章节说明的时间也会 + // 成为 SwiftUI 建立列表/分页树的预热窗口。 + applyOnboardingBackground(for: step) + + if waitsForInitialRender { + onboardingSectionPreparation = .completed + WatchHaptics.onboardingSuccess() + do { + try await Task.sleep(nanoseconds: 820_000_000) + } catch { + return + } + guard onboardingStep == step, + onboardingSectionIntro == section + else { return } + onboardingSectionPreparation = .ready + } + onboardingSectionIntroTask = nil + } + } + + /// 用户轻点纯黑分段页后淡出,再启用当前页面的第一项真实操作检测。 + private func handleOnboardingSectionIntroTap() { + guard let step = onboardingStep, + onboardingSectionIntro != nil, + onboardingSectionIntroTask == nil + else { return } + onboardingSectionIntroTask = Task { @MainActor in + withAnimation(WatchOnboardingMotion.sectionIntro) { + onboardingSectionIntro = nil + } + + do { + try await Task.sleep( + nanoseconds: WatchOnboardingMotion + .sectionIntroFadeNanoseconds + ) + } catch { + return + } + guard onboardingStep == step, + onboardingSectionIntro == nil + else { return } + WatchHaptics.selection() + configureOnboardingInput(for: step) + } + } + + /// 真实操作被验证后只做必要的教学页面收尾。 + /// + /// 点击、刷新或分页均由底层真实控件完成;这里仅提交教学状态。 + private func handleOnboardingOperation( + step: WatchOnboardingStep, + operation: WatchOnboardingOperation + ) { + if step == .overviewSwitcherTap, + operation == .tap(.mode) + { + // 用户已经真实看到目录;成功反馈期间只收起 Sheet。不要在这里 + // 提前挂载课程列表:列表的首次 scrollTo 会让实体表同步计算 + // 跨整学期布局,反而阻塞黑场和下一条教学提示。 + DispatchQueue.main.async { + showsModePicker = false + } + } + } + + /// 为当前步骤重置旁路输入桥;说明持续到用户开始操作,实际命中由页面负责。 + private func configureOnboardingInput(for step: WatchOnboardingStep) { + onboardingCrownEvaluationTask?.cancel() + onboardingCrownEvaluationTask = nil + onboardingInput.configure( + step: step, + operationAccepted: handleOnboardingOperation, + operationRejected: handleRejectedOnboardingOperation, + advance: showNextOnboardingStep + ) + } + + /// 错误输入可能已经真实打开目录、切换日期或关闭顶层页面;播放错号前, + /// 统一恢复到当前步骤开始时的页面。内部分页状态通过 identity 重建, + /// 根层路由则直接回到该步骤要求的模式、日期选择器或详情页。 + private func handleRejectedOnboardingOperation( + step: WatchOnboardingStep, + operation: WatchOnboardingOperation + ) { + _ = operation + onboardingCrownEvaluationTask?.cancel() + onboardingCrownEvaluationTask = nil + + performWithoutAnimation { + showsModePicker = false + installOnboardingRoute(for: step) + onboardingPageResetToken &+= 1 + } + } + + /// 最后一次长按成功后直接衔接小组件,整套教程结束才记录完成。 + private func showOnboardingWidgetIntroduction() { + presentWidgetOnboarding(from: .fullTutorial) + } + + /// 无需真实课表,也可从“使用指南”单独打开。 + private func presentWidgetOnboarding(from entry: WidgetOnboardingEntry = .guide) { + cancelOnboardingTasks() + onboardingInput.clear() + resetModeButtonPressState() + resetOnboardingTargets() + showsModePicker = false + showsDayDatePicker = false + selectedCourse = nil + showsOnboardingNotice = false + onboardingWaitsForSchedule = false + showsOnboardingScheduleAlert = false + withAnimation(WatchOnboardingMotion.pageTransition) { + onboardingStep = nil + widgetOnboardingEntry = entry + mode = .overview + } + } + + /// 将按钮、分页器或顶层手势中的真实操作报告给引导。 + private func reportOnboardingOperation( + _ operation: WatchOnboardingOperation, + location: CGPoint? = nil + ) { + guard onboardingStep != nil, + onboardingViewportSize.width > 0, + onboardingViewportSize.height > 0 + else { return } + onboardingInput.observe( + operation, + at: location, + controlCenters: onboardingControlCenters, + in: onboardingViewportSize + ) + } + + /// 固定布局入口直接使用语义目标中心,避免小屏幕圆角对坐标造成误差。 + private func reportOnboardingOperation( + _ operation: WatchOnboardingOperation, + target: WatchOnboardingTapTarget + ) { + guard onboardingStep != nil, + onboardingViewportSize.width > 0, + onboardingViewportSize.height > 0 + else { return } + let point = target.point( + in: onboardingViewportSize, + controlCenters: onboardingControlCenters + ) + onboardingInput.observe( + operation, + at: point, + controlCenters: onboardingControlCenters, + in: onboardingViewportSize + ) + } + + /// 周视图教学只接受当前随机目标课程;其他色块走错误恢复流程。 + private func reportOnboardingWeekCourseSelection(_ course: WatchCourse) { + guard onboardingStep != nil else { return } + if onboardingStep == .weekCourse, + course == onboardingWeekTargetCourse + { + reportOnboardingOperation( + .tap(.weekCourse), + target: .weekCourse + ) + } else { + reportOnboardingOperation( + .tap(.content), + target: .content + ) + } + } + + /// 日历自身的单一触摸层锁定轴向时,只隐去说明,不创建第二个手势。 + private func handleOnboardingTouchInputBegan() { + guard onboardingStep != nil else { return } + onboardingInput.beginOperation() + } + + /// 分页器完成自己的真实拖动后,再把已经执行的轴向旁路交给教学验证。 + private func handleOnboardingSwipeInput(_ axis: CalendarPagingDragAxis) { + switch axis { + case .horizontal: + reportOnboardingOperation(.horizontalSwipe) + case .vertical: + reportOnboardingOperation(.verticalSwipe) + } + } + + /// 原生纵向 ScrollView 确认内容已被手指带动时报告。 + private func handleOnboardingVerticalSwipeInput() { + // 原生 ScrollView 会在滚动完全进入 idle 后调用这里;自定义页面 + // 则由 DragGesture.onEnded 上报,二者都不会在手指仍按住时判断。 + reportOnboardingOperation(.verticalSwipe) + } + + /// 日、周、月标题栏左箭头共用同一个语义报告入口。 + private func handleOnboardingHeaderPreviousTap() { + reportOnboardingOperation( + .tap(.headerPrevious), + target: .headerPrevious + ) + } + + /// 日、周、月标题栏右箭头共用同一个语义报告入口。 + private func handleOnboardingHeaderNextTap() { + reportOnboardingOperation( + .tap(.headerNext), + target: .headerNext + ) + } + + /// 欢迎页不是测试题:轻点后立即进入第一项,不播放对错动画或成功反馈。 + private func handleOnboardingWelcomeTap() { + guard onboardingStep == .welcome, + onboardingRenderDataReady + else { return } + onboardingInput.clear() + WatchHaptics.selection() + showNextOnboardingStep() + } + + /// 欢迎页长按可跳过实操,完成后仍按整套引导记录;组件示例不依赖课表预热。 + private func handleOnboardingWelcomeHold() { + guard scenePhase == .active, onboardingStep == .welcome, + !showsWidgetOnboarding, onboardingSectionIntro == nil, + onboardingInput.showsPrompt, onboardingInput.feedback == nil + else { return } + WatchHaptics.onboardingSuccess() + presentWidgetOnboarding(from: .fullTutorial) + } + + /// 原生 ScrollView 的偏移回调只用来管理悬浮按钮。 + /// + /// watchOS 11 起由系统滚动阶段报告完成;watchOS 10 的兼容判定集中在 + /// InteractionAwareScrollView 内,根容器不另建输入来源推断。 + private func handlePassiveScrollInteraction() { + hideControls() + // 滚动内容一发生真实位移就隐去教学说明;对错仍等待系统 idle。 + onboardingInput.beginOperation() + } + + /// 自定义分页器每个表冠刻度都会调用;持续旋转时反复取消任务,只有 + /// 最后一个刻度后的短暂空闲才真正提交判断。 + private func handleOnboardingCrownInput() { + guard onboardingStep != nil else { return } + onboardingInput.beginOperation() + // 日视图的连续翻页步骤必须真正跨过一个日期页面才完成;普通刻度 + // 只负责隐去说明。停止后若仍未跨页,恢复说明而不判定成功或错误。 + if onboardingStep == .dayPagingCrown { + onboardingCrownEvaluationTask?.cancel() + onboardingCrownEvaluationTask = makeWatchAutoDismissTask( + after: 0.24 + ) { + guard onboardingStep == .dayPagingCrown else { return } + onboardingInput.restorePromptAfterIncompleteOperation() + } + return + } + onboardingCrownEvaluationTask?.cancel() + onboardingCrownEvaluationTask = makeWatchAutoDismissTask(after: 0.20) { + reportOnboardingOperation(.crown) + } + } + + /// 日视图确认表冠已经越过一整页后才提交“连续旋转翻日”教学。 + private func handleOnboardingDayCrownPageInput() { + guard onboardingStep == .dayPagingCrown else { return } + onboardingCrownEvaluationTask?.cancel() + onboardingCrownEvaluationTask = nil + reportOnboardingOperation(.crownPage) + } + + /// 详情教学使用与日/周/月教学相同日期中的第一项真实日程。 + private var onboardingTeachingCourse: WatchCourse? { + guard let date = onboardingTeachingDate else { + return store.snapshot?.courses.first { $0.startPeriod <= 10 } + ?? store.snapshot?.courses.first + } + let dayCourses = store.courses(on: date) + if let visibleCourse = dayCourses.first(where: { $0.startPeriod <= 10 }) { + return visibleCourse + } + + // 若教学日只有第 11 节后的事项,改从同一周寻找实际绘制在 1–10 + // 节网格中的色块;教学脉冲才能稳定落在一个真实可点课程上。 + let weekCourses = store.courses( + startingAt: calendarWeekStart(containing: date), + dayCount: 7 + ) + return weekCourses.first { $0.startPeriod <= 10 } + ?? dayCourses.first + ?? store.snapshot?.courses.first + } + + /// 周视图教学从当前展示周的可见色块中随机选择一个目标。 + /// + /// 这里只选择课程数据;`WeekScheduleGridGeometry` 随后使用星期列和 + /// 开始/结束节次反算色块矩形,无需等待渲染后的视图边界。 + private func randomOnboardingWeekCourse() -> WatchCourse? { + guard let date = onboardingTeachingDate + ?? store.recommendedOnboardingDate + else { return nil } + return store.courses( + startingAt: calendarWeekStart(containing: date), + dayCount: 7 + ) + .filter { $0.startPeriod <= 10 } + .randomElement() + } + + /// 详情页沿用刚刚在周视图中实际点中的随机课程;若教学尚未进入周视图, + /// 再回退到教学日期中的课程。 + private var onboardingDetailTeachingCourse: WatchCourse? { + onboardingWeekTargetCourse ?? onboardingTeachingCourse + } + + /// 结束页停留两秒后完成阅读,不推断用户已经安装系统小组件。 + private func finishOnboarding() { + guard let entry = widgetOnboardingEntry else { return } + cancelOnboardingTasks() + onboardingInput.clear() + selectedCourse = nil + resetOnboardingTargets() + if case .fullTutorial = entry { + UserDefaults.standard.set(true, forKey: WatchPersistentCacheKey.completedOnboarding) + } + WatchHaptics.onboardingSuccess() + withAnimation(WatchOnboardingMotion.pageTransition) { + widgetOnboardingEntry = nil + onboardingStep = nil + mode = .overview + overviewOpenRevision &+= 1 + } + showOnboardingCompletionNotice() + } + + /// 完成提示可在不影响课表操作的情况下自动消失。 + private func showOnboardingCompletionNotice() { + onboardingNoticeTask?.cancel() + keepControlsVisibleDuringRefresh() + withAnimation(.easeInOut(duration: 0.2)) { + showsOnboardingNotice = true + } + onboardingNoticeTask = makeWatchAutoDismissTask(after: 15) { + dismissOnboardingCompletionNotice(playsHaptic: false) + } + } + + /// 单击和计时共用关闭函数,只有显式单击才播放反馈。 + private func dismissOnboardingCompletionNotice( + playsHaptic: Bool = true + ) { + onboardingNoticeTask?.cancel() + guard showsOnboardingNotice else { return } + if playsHaptic { + WatchHaptics.selection() + } + withAnimation(.easeInOut(duration: 0.22)) { + showsOnboardingNotice = false + } + revealControls() + } + + /// 同步提示的系统版本适配。 + @ViewBuilder + private var syncCompletionToast: some View { + if #available(watchOS 26.0, *) { + syncCompletionLabel + .glassEffect(.regular, in: Capsule()) + .glassEffectTransition(.materialize) + } else { + syncCompletionLabel + .background(.ultraThinMaterial, in: Capsule()) + } + } + + /// 提示本体保持紧凑,避免遮挡底部的模式按钮。 + private var syncCompletionLabel: some View { + Label("同步完成", systemImage: "checkmark.circle.fill") + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 9) + .padding(.vertical, 5) + } + + /// 整块表盘底部的过期提示;不属于课程内容布局。 + @ViewBuilder + private var staleScheduleNotice: some View { + Group { + if #available(watchOS 26.0, *) { + staleScheduleNoticeLabel + .glassEffect(.regular, in: Capsule()) + .glassEffectTransition(.materialize) + } else { + staleScheduleNoticeLabel + .background(.ultraThinMaterial, in: Capsule()) + } + } + .contentShape(Capsule()) + .onTapGesture { + WatchHaptics.selection() + withAnimation(.easeInOut(duration: 0.18)) { + dismissesStaleScheduleNotice = true + } + } + .accessibilityAddTraits(.isButton) + .accessibilityLabel( + watchLocalizedString("关闭课表过期提示") + ) + } + + private var staleScheduleNoticeLabel: some View { + Label( + "课表可能已过期", + systemImage: "exclamationmark.triangle" + ) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.orange) + .lineLimit(1) + .minimumScaleFactor(0.8) + .padding(.horizontal, 9) + .padding(.vertical, 5) + } + + /// 有缓存时的超时提示,压缩成两行以尽量少遮挡课表内容。 + @ViewBuilder + private var cachedScheduleNotice: some View { + Group { + if #available(watchOS 26.0, *) { + cachedScheduleNoticeLabel + .glassEffect(.regular, in: Capsule()) + .glassEffectTransition(.materialize) + } else { + cachedScheduleNoticeLabel + .background(.ultraThinMaterial, in: Capsule()) + } + } + .contentShape(Capsule()) + .onTapGesture(perform: dismissCachedScheduleNotice) + .accessibilityAddTraits(.isButton) + .accessibilityLabel("关闭缓存提示") + } + + private var cachedScheduleNoticeLabel: some View { + HStack(spacing: 5) { + Image(systemName: "iphone.slash") + .font(.caption2.weight(.semibold)) + VStack(alignment: .leading, spacing: 0) { + Text("已加载缓存课表") + .font(.caption2.weight(.semibold)) + Text("更新请打开手机 XDYou 以同步") + .font(.system(size: 8.5)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + + /// 引导完成提示沿用紧凑提示的材质与关闭行为。 + @ViewBuilder + private var onboardingCompletionNotice: some View { + Group { + if #available(watchOS 26.0, *) { + onboardingCompletionNoticeLabel + .glassEffect(.regular, in: Capsule()) + .glassEffectTransition(.materialize) + } else { + onboardingCompletionNoticeLabel + .background(.ultraThinMaterial, in: Capsule()) + } + } + .contentShape(Capsule()) + .onTapGesture { + dismissOnboardingCompletionNotice() + } + .accessibilityAddTraits(.isButton) + .accessibilityLabel( + watchLocalizedString("关闭新手引导提示") + ) + } + + private var onboardingCompletionNoticeLabel: some View { + Label( + watchLocalizedString("长按右下角切换按钮重新进入新手引导"), + systemImage: "hand.tap.fill" + ) + .font(.system(size: 9, weight: .semibold)) + .lineLimit(2) + .multilineTextAlignment(.leading) + .minimumScaleFactor(0.78) + .padding(.horizontal, 8) + .padding(.vertical, 5) + } + + /// 缓存提示最多展示 15 秒;手机提前回复时由超时状态变化立即撤下。 + private func updateCachedScheduleNotice(didTimeOut: Bool) { + cachedScheduleNoticeTask?.cancel() + guard didTimeOut, + store.hasCachedScheduleContent + else { + withAnimation(.easeInOut(duration: 0.18)) { + showsCachedScheduleNotice = false + } + return + } + + withAnimation(.easeInOut(duration: 0.2)) { + showsCachedScheduleNotice = true + } + cachedScheduleNoticeTask = makeWatchAutoDismissTask(after: 15) { + withAnimation(.easeInOut(duration: 0.28)) { + showsCachedScheduleNotice = false + } + } + } + + /// 用户轻点提示时立即撤下,并取消尚未结束的自动隐藏任务。 + private func dismissCachedScheduleNotice() { + cachedScheduleNoticeTask?.cancel() + WatchHaptics.selection() + withAnimation(.easeInOut(duration: 0.18)) { + showsCachedScheduleNotice = false + } + } + + /// 开始或停止刷新图标的连续旋转。 + private func updateRefreshAnimation(isRefreshing: Bool) { + if isRefreshing { + refreshRotation = 0 + withAnimation( + .linear(duration: 0.8).repeatForever(autoreverses: false) + ) { + refreshRotation = 360 + } + } else { + withAnimation(.easeOut(duration: 0.18)) { + refreshRotation = 0 + } + } + } + + /// 用刷新完成计数区分多轮异步提示,旧任务不会隐藏新提示。 + private func showCompletion(for count: Int) { + WatchHaptics.success() + withAnimation( + .spring( + response: 0.36, + dampingFraction: 0.62, + blendDuration: 0.08 + ) + ) { + showsSyncCompletion = true + } + syncCompletionTask?.cancel() + syncCompletionTask = makeWatchAutoDismissTask(after: 1.8) { + guard count == store.completedRefreshCount else { return } + withAnimation(.easeInOut(duration: 0.28)) { + showsSyncCompletion = false + } + } + } + + /// 显示两个悬浮按钮,并重新开始自动隐藏倒计时。 + private func revealControls() { + hideControlsTask?.cancel() + withAnimation(.easeOut(duration: 0.18)) { + controlsVisible = true + } + hideControlsTask = makeWatchAutoDismissTask(after: 2.6) { + guard !store.isRefreshing, + onboardingStep == nil, + !showsOnboardingNotice + else { return } + withAnimation(.easeIn(duration: 0.2)) { + controlsVisible = false + } + } + } + + /// 空白区域轻点使用同一个显隐入口。同步、启动等待和完成提示期间按钮 + /// 按既有规则强制可见;普通浏览和对应引导步骤才允许点击隐藏。 + private func toggleControlsFromContentTap() { + if onboardingStep?.operation == .verticalSwipe { + // 纵向滑动教学由 ScrollView 的原生滚动阶段和拖动结束兜底 + // 独占判定。短内容橡皮筋在个别系统版本上可能补发父层轻点, + // 无论说明淡出是否已提交,都不能把这次补发当成错误操作。 + return + } + reportOnboardingOperation(.tap(.content), target: .content) + if store.isRefreshing + || store.isAwaitingLaunchSyncReply + || showsOnboardingNotice + { + keepControlsVisibleDuringRefresh() + return + } + if controlsVisible { + hideControls() + } else { + revealControls() + } + } + + /// 在两个教学步骤开始前设置确定的初始状态:先展示按钮教用户隐藏, + /// 再保持隐藏教用户重新显示。其他步骤继续由引导强制展示入口。 + private func prepareControlVisibility(for step: WatchOnboardingStep) { + guard step.teachesControlVisibility else { return } + hideControlsTask?.cancel() + hideControlsTask = nil + performWithoutAnimation { + controlsVisible = step == .overviewControlsHide + } + } + + /// 同步开始时取消隐藏任务,并立即恢复两个悬浮入口。 + private func keepControlsVisibleDuringRefresh() { + hideControlsTask?.cancel() + guard !controlsVisible else { return } + withAnimation(.easeOut(duration: 0.18)) { + controlsVisible = true + } + } + + /// 表冠或滚动发生时立即隐藏按钮,释放课程内容区域。 + private func hideControls() { + guard !store.isRefreshing, + !store.isAwaitingLaunchSyncReply, + onboardingStep == nil + || onboardingStep?.teachesControlVisibility == true, + !showsOnboardingNotice + else { + keepControlsVisibleDuringRefresh() + return + } + hideControlsTask?.cancel() + hideControlsTask = nil + // 表冠连续旋转会高频调用该入口。按钮第一次隐藏后不再创建无意义的 + // 动画事务,避免每个 detent 都让根 ZStack 与三页课表重新参与更新。 + guard controlsVisible else { return } + withAnimation(.easeIn(duration: 0.16)) { + controlsVisible = false + } + } + + /// 悬浮按钮共用表盘坐标系,因此课表页面与独立月视图具有一致的命中位置。 + private func floatingControlsLayer(size: CGSize) -> some View { + let edgeInset = RootScheduleLayout.edgeInset(for: size) + return ZStack { + refreshControl + .background { + floatingControlFrameReader(.refresh) + } + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .topTrailing + ) + .padding( + .top, + RootScheduleLayout.refreshTopInset(for: size.height) + ) + .padding(.trailing, edgeInset) + + modeControl + .background { + floatingControlFrameReader(.mode) + } + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .bottomTrailing + ) + .padding(.trailing, edgeInset) + .padding( + .bottom, + RootScheduleLayout.modeBottomInset(for: size.height) + ) + } + } + + /// 透明 GeometryReader 只记录系统玻璃按钮渲染后的真实边界,不参与布局。 + private func floatingControlFrameReader( + _ kind: RootFloatingControlKind + ) -> some View { + GeometryReader { proxy in + let frame = proxy.frame(in: .global) + Color.clear + .onAppear { + recordFloatingControlFrame(frame, kind: kind) + } + .onChange(of: frame) { _, newFrame in + recordFloatingControlFrame(newFrame, kind: kind) + } + } + .allowsHitTesting(false) + } + + private func recordFloatingControlFrame( + _ frame: CGRect, + kind: RootFloatingControlKind + ) { + switch kind { + case .refresh: + refreshControlGlobalFrame = frame + case .mode: + modeControlGlobalFrame = frame + } + } + + /// 记录标题栏、课程色块和详情按钮的真实全局边界。 + private func recordOnboardingTargetFrame( + _ target: WatchOnboardingTapTarget, + frame: CGRect + ) { + guard !frame.isEmpty else { return } + switch target { + case .detailClose: + detailCloseGlobalFrame = frame + default: + break + } + } + + /// 接收周网格公式算出的目标矩形,并转换为教学层使用的中心点。 + private func recordOnboardingWeekCourseFrame( + _ course: WatchCourse, + frame: CGRect + ) { + guard course == onboardingWeekTargetCourse, + onboardingStep == .weekCourse, + !frame.isEmpty, + !onboardingViewportGlobalFrame.isEmpty, + isMostlyVisibleOnboardingTarget(frame) + else { return } + // 矩形的宽高和本地坐标来自绘制公式,GeometryReader 只提供整个 + // 网格的全局原点。首次稳定进入视口后冻结,过渡帧不会拖动提示圆。 + guard weekCourseGlobalCenter == nil else { return } + weekCourseGlobalCenter = CGPoint(x: frame.midX, y: frame.midY) + } + + /// 模式切换或分页过渡期间,当前页也可能暂时位于视口边缘。教学只 + /// 接收至少 80% 面积已经进入表盘的目标,避免冻结过渡中的坐标。 + private func isMostlyVisibleOnboardingTarget(_ frame: CGRect) -> Bool { + guard frame.width > 0, frame.height > 0 else { return false } + let visibleFrame = onboardingViewportGlobalFrame.intersection(frame) + guard !visibleFrame.isNull, !visibleFrame.isEmpty else { return false } + let targetArea = frame.width * frame.height + let visibleArea = visibleFrame.width * visibleFrame.height + return visibleArea / targetArea >= 0.80 + } + + /// 将全局按钮中心转换为教学 Overlay 使用的表盘本地坐标。 + private var onboardingControlCenters: WatchOnboardingControlCenters { + WatchOnboardingControlCenters( + refresh: localControlCenter(for: refreshControlGlobalFrame), + mode: localControlCenter(for: modeControlGlobalFrame), + weekCourse: localControlCenter( + forGlobalPoint: weekCourseGlobalCenter + ), + detailClose: localControlCenter(for: detailCloseGlobalFrame) + ) + } + + private func localControlCenter(for frame: CGRect) -> CGPoint? { + guard !frame.isEmpty else { return nil } + return localControlCenter( + forGlobalPoint: CGPoint(x: frame.midX, y: frame.midY) + ) + } + + /// 把整个屏幕坐标系中的绝对中心转换为全屏教学 Overlay 的本地坐标。 + private func localControlCenter(forGlobalPoint point: CGPoint?) -> CGPoint? { + guard let point, !onboardingViewportGlobalFrame.isEmpty else { + return nil + } + return CGPoint( + x: point.x - onboardingViewportGlobalFrame.minX, + y: point.y - onboardingViewportGlobalFrame.minY + ) + } +} diff --git a/watchOS/Views/WatchInteractionSupport.swift b/watchOS/Views/WatchInteractionSupport.swift new file mode 100644 index 00000000..6793af51 --- /dev/null +++ b/watchOS/Views/WatchInteractionSupport.swift @@ -0,0 +1,315 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import Foundation +#if canImport(WatchKit) +import SwiftUI +import WatchKit +#endif + +/// 创建一个可取消的界面自动收起任务。 +/// +/// 缓存提示和新手引导完成提示共用这一入口,避免各自在根视图里重复维护 +/// `Task.sleep`、取消检查和主线程回调。调用方仍负责保存并取消返回的任务。 +@MainActor +func makeWatchAutoDismissTask( + after seconds: TimeInterval, + action: @escaping @MainActor () -> Void +) -> Task { + Task { @MainActor in + let nanoseconds = UInt64(max(0, seconds) * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + guard !Task.isCancelled else { return } + action() + } +} + +/// 按住 0.3 秒后持续发出渐强、加速的短脉冲,三秒时结束。 +struct WatchHoldFeedbackPulse: Equatable { + private static let holdSeconds = 3.0 + private static let startDelaySeconds = 0.3 + static let holdDuration: Duration = .seconds(holdSeconds) + static let startDelay: Duration = .seconds(startDelaySeconds) + + // 即使相邻两次强度相同,也要让 sensoryFeedback 识别为新的脉冲。 + let sequence: Int + private let strengthLevel: Int + let interval: Duration + + init(sequence: Int, elapsed: Duration) { + self.sequence = sequence + let components = elapsed.components + let elapsedSeconds = Double(components.seconds) + + Double(components.attoseconds) / 1_000_000_000_000_000_000 + let progress = min(1, max(0, + (elapsedSeconds - Self.startDelaySeconds) + / (Self.holdSeconds - Self.startDelaySeconds) + )) + // 固定 13 档强度,避免每次按压都创建不同浮点强度的反馈对象。 + strengthLevel = Int((progress * 12).rounded()) + // 留出至少 120ms,避免过密触发使系统打断前一次触觉。 + interval = .seconds(0.28 - 0.16 * progress) + } +} + +/// 模式按钮与欢迎页共用三秒长按计时;调用方负责按压有效性和任务取消。 +@MainActor +func makeWatchHoldFeedbackTask( + isActive: @escaping @MainActor () -> Bool, + onPulse: @escaping @MainActor (WatchHoldFeedbackPulse) -> Void, + onComplete: @escaping @MainActor () -> Void +) -> Task { + let clock = ContinuousClock() + let startedAt = clock.now + let completionAt = startedAt.advanced(by: WatchHoldFeedbackPulse.holdDuration) + return Task { @MainActor in + do { + try await clock.sleep(until: startedAt.advanced(by: WatchHoldFeedbackPulse.startDelay)) + var sequence = 0 + while true { + guard !Task.isCancelled, isActive() else { return } + let now = clock.now + guard now < completionAt else { break } + let pulse = WatchHoldFeedbackPulse( + sequence: sequence, + elapsed: startedAt.duration(to: now) + ) + onPulse(pulse) + sequence &+= 1 + // 只按实际经过时间推进,不补发卡顿期间错过的脉冲,完成时刻固定为三秒。 + try await clock.sleep(until: min(now.advanced(by: pulse.interval), completionAt)) + } + } catch { + return + } + guard !Task.isCancelled, isActive() else { return } + onComplete() + } +} + +#if canImport(WatchKit) +extension WatchHoldFeedbackPulse { + var feedback: SensoryFeedback { + let intensity = 0.25 + 0.75 * Double(strengthLevel) / 12 + switch strengthLevel { + case 0..<4: return .impact(weight: .light, intensity: intensity) + case 4..<8: return .impact(weight: .medium, intensity: intensity) + default: return .impact(weight: .heavy, intensity: intensity) + } + } +} +#endif + +/// 手表端统一的轻量触觉反馈入口。 +/// +/// 只在用户完成明确操作或跨越一个导航刻度时播放,避免表冠连续转动期间 +/// 高频触发导致触觉含义变得模糊。 +#if canImport(WatchKit) +@MainActor +enum WatchHaptics { + static func selection() { + WKInterfaceDevice.current().play(.click) + } + + /// 到达边界时使用与课程列表表冠刻度一致的短点击触觉。 + static func boundary(_ amount: Int) { + _ = amount + WKInterfaceDevice.current().play(.click) + } + + static func navigation(_ amount: Int) { + // Core Haptics 不对普通 watchOS App target 开放;使用课程列表同款 + // 短点击组成双脉冲。边界为单击、翻页为双击,同时避开 `.start` + // 等会附带明显系统提示音的反馈类型。 + _ = amount + let device = WKInterfaceDevice.current() + device.play(.click) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { + device.play(.click) + } + } + + static func refreshStarted() { + WKInterfaceDevice.current().play(.click) + } + + static func success() { + WKInterfaceDevice.current().play(.click) + } + + /// 新手引导正确操作使用系统“成功”双段触觉,语义与支付成功一致。 + static func onboardingSuccess() { + WKInterfaceDevice.current().play(.success) + } + + /// 操作种类、方向轴或点击位置不符时使用系统失败反馈。 + static func onboardingError() { + WKInterfaceDevice.current().play(.failure) + } +} +#endif + +/// 拖出按钮后,本轮触摸始终取消;手指移回也不能重启长按或补发点击。 +/// 状态机不保存计时任务,页面离开时由调用方取消任务并 reset。 +struct WatchPressSession { + private(set) var isActive = false + private(set) var isCancelled = false + + mutating func begin() -> Bool { + guard !isActive, !isCancelled else { return false } + isActive = true + return true + } + + mutating func cancel() { + isActive = false + isCancelled = true + } + + mutating func finish(didTriggerLongPress: Bool) -> Bool { + let shouldTap = isActive && !isCancelled && !didTriggerLongPress + reset() + return shouldTap + } + + mutating func reset() { + isActive = false + isCancelled = false + } +} + +/// 系统 idle 与触摸兜底共用一次性完成门。步骤切换或手势取消会推进代次, +/// 因而旧回调即使已排入主线程,也不能完成新步骤。 +struct WatchInputCompletionGate { + private(set) var generation = 0 + private var completedGeneration: Int? + + var hasCompletedTouch: Bool { completedGeneration == generation } + + mutating func begin() { + generation &+= 1 + } + + mutating func completeTouch(for expectedGeneration: Int) -> Bool { + guard expectedGeneration == generation, !hasCompletedTouch else { return false } + completedGeneration = generation + return true + } +} + +/// 日、周、月分页共用的表冠停止协调器。 +/// +/// watchOS 正常会在停止旋转后发送 `onIdle`,但实体表在焦点切换或系统 +/// ScrollView 参与时偶尔会漏发。协调器同时维护两种互斥计时: +/// +/// - 每个有效刻度重置 360ms 兜底; +/// - 收到 `onIdle` 后改用 90ms 短确认窗。 +/// +/// 新刻度、页面吸附或视图退出都会调用 `cancel()`,因此同一页面永远只有 +/// 一个待执行任务。 +@MainActor +final class CalendarCrownIdleCoordinator { + private static let fallbackDelay: UInt64 = 360_000_000 + private static let idleConfirmationDelay: UInt64 = 90_000_000 + private var task: Task? + + /// 安装实体表漏发 `onIdle` 时使用的较长兜底计时。 + func scheduleFallback( + action: @escaping @MainActor () -> Void + ) { + schedule(afterNanoseconds: Self.fallbackDelay, action: action) + } + + /// 系统已报告空闲时,用短窗口确认期间没有新刻度。 + func scheduleIdleConfirmation( + action: @escaping @MainActor () -> Void + ) { + schedule( + afterNanoseconds: Self.idleConfirmationDelay, + action: action + ) + } + + /// 取消旧任务后安装唯一的新停止检测任务。 + private func schedule( + afterNanoseconds: UInt64, + action: @escaping @MainActor () -> Void + ) { + cancel() + task = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: afterNanoseconds) + guard !Task.isCancelled else { return } + self?.task = nil + action() + } + } + + /// 新输入和页面生命周期变化共用的取消入口。 + func cancel() { + task?.cancel() + task = nil + } + + deinit { + task?.cancel() + } +} + +/// 一次表冠输入更新的语义结果。 +/// +/// 页面只需要关心方向、是否开始了新一轮旋转、以及是否发生反转;原始 +/// 时间戳和上一次方向统一由 `WatchCrownTurnSession` 管理。 +struct WatchCrownTurnUpdate { + let direction: Int + let startsNewSession: Bool + let reversesDirection: Bool +} + +/// 日、周、月视图共用的表冠连续旋转状态机。 +/// +/// 该类型不计算位移,也不播放触觉;它只提供两项基础能力: +/// +/// 1. 超过 0.35 秒没有输入时开始新一轮; +/// 2. 识别同一轮旋转中的方向反转。 +/// +/// 具体的卡片滚动、页面位移和吸附阈值由各视图自行决定。 +struct WatchCrownTurnSession { + private static let inactivityTimeout: TimeInterval = 0.35 + + private var lastEventTime: TimeInterval? + private var direction = 0 + + /// 接收一次非零表冠变化,并返回本次输入对应的会话语义。 + mutating func register( + delta: Double, + now: TimeInterval = ProcessInfo.processInfo.systemUptime + ) -> WatchCrownTurnUpdate? { + guard delta.isFinite, now.isFinite, abs(delta) > .ulpOfOne else { return nil } + + // 使用单调时钟,手机校时不会把两次独立旋转合并为同一轮。 + let startsNewSession = lastEventTime.map { + now < $0 || now - $0 > Self.inactivityTimeout + } ?? true + let newDirection = delta > 0 ? 1 : -1 + let reversesDirection = !startsNewSession + && direction != 0 + && newDirection != direction + + lastEventTime = now + direction = newDirection + + return WatchCrownTurnUpdate( + direction: newDirection, + startsNewSession: startsNewSession, + reversesDirection: reversesDirection + ) + } + + /// 主动结束当前表冠会话。 + /// + /// 吸附完成后清空旧时间和方向,下一个刻度会作为新会话处理。 + mutating func reset() { + lastEventTime = nil + direction = 0 + } +} diff --git a/watchOS/Views/WatchOnboardingView.swift b/watchOS/Views/WatchOnboardingView.swift new file mode 100644 index 00000000..0344628b --- /dev/null +++ b/watchOS/Views/WatchOnboardingView.swift @@ -0,0 +1,2018 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import SwiftUI + +private struct WatchOnboardingAnimationsPausedKey: EnvironmentKey { + static let defaultValue = false +} + +extension EnvironmentValues { + var watchOnboardingAnimationsPaused: Bool { + get { self[WatchOnboardingAnimationsPausedKey.self] } + set { self[WatchOnboardingAnimationsPausedKey.self] = newValue } + } +} + +/// 新手引导统一采用接近 watchOS 系统控件的短促响应节奏。 +/// +/// 动画参数集中在这里,避免各页面分别累积固定等待时间。结果绘制允许圆环和 +/// 标记轻微重叠,输入完成后能立即得到反馈,而不需要等待上一段完全结束。 +enum WatchOnboardingMotion { + static let prompt = Animation.easeOut(duration: 0.24) + // 用户开始真实操作后以淡出结束说明,避免低功耗显示下的单帧跳变。 + static let promptDismiss = Animation.easeOut(duration: 0.32) + static let feedback = Animation.spring( + response: 0.30, + dampingFraction: 0.86 + ) + static let feedbackDismiss = Animation.easeInOut(duration: 0.18) + static let pageTransition = Animation.spring( + response: 0.34, + dampingFraction: 0.90 + ) + static let progress = Animation.spring( + response: 0.42, + dampingFraction: 0.88 + ) + static let welcome = Animation.spring( + response: 0.52, + dampingFraction: 0.90 + ) + static let welcomePrompt = Animation.easeOut(duration: 0.32) + static let ring = Animation.easeOut(duration: 0.46) + static let result = Animation.spring( + response: 0.38, + dampingFraction: 0.78 + ) + static let operationHint = Animation.easeInOut(duration: 0.88) + /// 五个功能分段之间使用纯黑过场,淡入淡出比普通步骤稍慢,给用户 + /// 留出明确的“开始介绍下一视图”节奏,同时不会拖慢后续实操。 + static let sectionIntro = Animation.easeInOut(duration: 0.34) + + static let resultOverlapDelayNanoseconds: UInt64 = 300_000_000 + static let sectionIntroFadeNanoseconds: UInt64 = 340_000_000 + static let successVisibleDuration: TimeInterval = 0.95 + static let holdSuccessVisibleDuration: TimeInterval = 0.55 + /// 概览目录操作先留出观察窗口,再以纯黑成功反馈衔接课程列表章节。 + static let overviewExitTapResultDelay: TimeInterval = 0.18 + static let overviewExitSuccessVisibleDuration: TimeInterval = 0.62 + static let errorVisibleDuration: TimeInterval = 1.35 + /// 点击完成后先让真实页面变化短暂可见,再覆盖正确结果。 + static let tapResultDelay: TimeInterval = 0.35 +} + +/// 新手引导遮罩的公共几何参数。 +/// +/// 标题与底部说明必须使用完全相同的左右边界,否则在小表盘上会显得偏心; +/// 表冠高度则依据 Apple Watch 正面参考图测得的物理中心比例统一计算。 +private enum WatchOnboardingOverlayLayout { + static let horizontalInset: CGFloat = 4 + static let titleTopInset: CGFloat = 39 + static let titleHeight: CGFloat = 28 + static let panelCornerRadius: CGFloat = 13 + static let instructionBottomInset: CGFloat = 17 + + /// 表冠提示的垂直中心,按教学视口高度等比定位。 + static let crownCenterHeightRatio: CGFloat = 0.27 + + /// 左翻页提示按表盘宽度缩放;198pt 参考表盘上的横坐标约为 21pt。 + static let previousPageCueXRatio: CGFloat = 0.106 + + /// 右翻页提示按表盘宽度缩放;198pt 参考表盘上的横坐标约为 124pt。 + static let nextPageCueXRatio: CGFloat = 0.626 +} + +/// 五个顶层视图对应的教学分段。 +/// +/// 分段页只是实操之间的视觉过场,不占用教学步骤,也不改变进度。 +/// 标题直接复用视图目录的本地化名称,避免目录与引导出现两套译文。 +enum WatchOnboardingSection: Equatable { + case overview + case courseList + case day + case week + case month + + var mode: WatchCalendarMode { + switch self { + case .overview: .overview + case .courseList: .courseList + case .day: .day + case .week: .week + case .month: .month + } + } + + var title: String { + mode.title + } + + /// 五个真实视图依次占第 1...5 阶段;欢迎页不参与章节进度。 + var progressStage: Int { + switch self { + case .overview: 1 + case .courseList: 2 + case .day: 3 + case .week: 4 + case .month: 5 + } + } + + /// 只在每个视图的第一项教学前插入分段页。 + static func starting(at step: WatchOnboardingStep) -> Self? { + switch step { + case .overviewSwipe: + .overview + case .courseListSwipe: + .courseList + case .dayBrowseSwipe: + .day + case .weekPagingArrow: + .week + case .monthPagingArrow: + .month + default: + nil + } + } +} + +/// 课程列表章节开始前的底层渲染准备状态。 +/// +/// 状态只显示在纯黑章节页上,不会叠在正在进行的操作教学之上。 +enum WatchOnboardingPreparationState: Equatable { + case ready + case loading + case completed +} + +/// 引导点击位置的语义名称。 +/// +/// 位置以真实页面的稳定布局为基准计算,而不是把说明文字本身做成按钮。 +/// 用户因此会在刷新、模式、日期标题、箭头或详情关闭按钮的实际位置完成操作。 +enum WatchOnboardingTapTarget: Equatable { + case anywhere + case refresh + case mode + case content + case headerPrevious + case headerNext + case headerTitle + case calendarDate + case weekCourse + case detailClose + case monthTitle + + /// 目标中心点。所有值只服务于旁路位置验证,不改变底层页面布局。 + func point( + in size: CGSize, + controlCenters: WatchOnboardingControlCenters? = nil + ) -> CGPoint { + switch self { + case .anywhere, .content: + CGPoint(x: size.width * 0.5, y: size.height * 0.52) + case .refresh: + controlCenters?.refresh + ?? RootScheduleLayout.refreshControlCenter(in: size) + case .mode: + controlCenters?.mode + ?? RootScheduleLayout.modeControlCenter(in: size) + case .headerPrevious: + fixedHeaderArrowPoint(in: size, isNext: false) + case .headerNext: + fixedHeaderArrowPoint(in: size, isNext: true) + case .headerTitle, .monthTitle: + CGPoint(x: size.width * 0.47, y: max(28, size.height * 0.17)) + case .calendarDate: + CGPoint(x: size.width * 0.5, y: size.height * 0.53) + case .weekCourse: + validPoint(controlCenters?.weekCourse, in: size) + ?? CGPoint(x: size.width * 0.48, y: size.height * 0.52) + case .detailClose: + validPoint(controlCenters?.detailClose, in: size) + ?? CGPoint(x: size.width - 25, y: max(48, size.height * 0.29)) + } + } + + /// 这些目标由系统 Toolbar、滚动详情或周网格决定位置,估算坐标只能 + /// 用于命中兜底,不能用于绘制教学动画,否则首帧会从估算位置滑过去。 + var requiresMeasuredCuePoint: Bool { + switch self { + case .refresh, .mode, .weekCourse, .detailClose: + true + default: + false + } + } + + /// 返回已经由真实页面测得的目标中心;没有首帧布局时返回 nil。 + func measuredPoint( + controlCenters: WatchOnboardingControlCenters + ) -> CGPoint? { + switch self { + case .refresh: + controlCenters.refresh + case .mode: + controlCenters.mode + case .weekCourse: + controlCenters.weekCourse + case .detailClose: + controlCenters.detailClose + default: + nil + } + } + + /// 绘制位置与命中位置分开:需要实测的控件在坐标到达前宁可暂不显示, + /// 也不先画在估算位置;普通固定布局目标仍可立即使用响应式坐标。 + func cuePoint( + in size: CGSize, + controlCenters: WatchOnboardingControlCenters + ) -> CGPoint? { + if requiresMeasuredCuePoint { + return validPoint( + measuredPoint(controlCenters: controlCenters), + in: size + ) + } + let basePoint = point(in: size, controlCenters: controlCenters) + // 左右箭头只调整教学动画的绘制位置;实际按钮命中和操作判定 + // 仍使用上面的固定中心,不扩大或移动响应区。 + if self == .headerPrevious { + return CGPoint( + x: size.width + * WatchOnboardingOverlayLayout.previousPageCueXRatio, + y: basePoint.y + ) + } else if self == .headerNext { + return CGPoint( + x: size.width * WatchOnboardingOverlayLayout.nextPageCueXRatio, + y: basePoint.y + ) + } + return basePoint + } + + /// ScrollView 重建期间可能短暂上报上一帧的屏外坐标;只有仍位于表盘 + /// 内部的实测中心才参与教学动画,否则立即使用响应式回退位置。 + private func validPoint(_ point: CGPoint?, in size: CGSize) -> CGPoint? { + guard let point, + point.x.isFinite, + point.y.isFinite, + point.x >= 0, + point.x <= size.width, + point.y >= 0, + point.y <= size.height + else { return nil } + return point + } + + /// 日、周、月共用一个 116pt 宽的系统标题栏,因此两枚箭头的视觉中心 + /// 是稳定的。直接按表盘宽度缩放坐标,避免分页重建时等待 + /// GeometryReader 采样而造成提示先漂移、后归位或短暂消失。 + private func fixedHeaderArrowPoint( + in size: CGSize, + isNext: Bool + ) -> CGPoint { + let scale = min(1, max(0.82, size.width / 198)) + let leading = max(14, 16 * scale) + return CGPoint( + x: isNext ? leading + 98 * scale : leading, + y: max(25, size.height * 0.12) + ) + } + + var hitRadius: CGFloat { + switch self { + case .anywhere, .content: + 92 + case .calendarDate, .weekCourse: + 38 + default: + 30 + } + } +} + +/// 根页面实测得到的教学目标中心点。 +/// +/// 教学动画优先使用真实几何位置;按钮尚未完成首帧布局时才回退到响应式公式。 +struct WatchOnboardingControlCenters: Equatable { + var refresh: CGPoint? + var mode: CGPoint? + var weekCourse: CGPoint? + var detailClose: CGPoint? +} + +/// 读取无法由确定性布局公式推算的真实控件边界,不绘制内容也不参与命中。 +/// +/// 当前用于滚动详情中的关闭按钮。周课程色块已有统一网格几何模型,直接 +/// 按星期和节次反算,无需使用渲染后采样器。 +struct WatchOnboardingFrameReader: View { + let report: (CGRect) -> Void + + var body: some View { + GeometryReader { proxy in + let frame = proxy.frame(in: .global) + Color.clear + .onAppear { report(frame) } + .onChange(of: frame) { _, newFrame in + report(newFrame) + } + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} + +/// 引导可以自动识别的原子操作。 +/// +/// 每一步只匹配一个原子操作。产生相同页面效果的箭头、滑动和表冠被拆成相邻 +/// 步骤,让初次使用者分别实际操作一次。 +enum WatchOnboardingOperation: Equatable { + case tap(WatchOnboardingTapTarget) + case longPress(WatchOnboardingTapTarget) + case verticalSwipe + case horizontalSwipe + case crown + /// 日视图连续旋转表冠并真正跨过一个日期页面。 + case crownPage +} + +/// 交互式新手引导的顺序状态机。 +/// +/// 顺序严格跟随模式目录:概览、课程列表、日视图、周视图、月视图。日视图 +/// 的基础操作结束后会提前进入一次日期选择器,完整演示打开、选中和返回。 +enum WatchOnboardingStep: Int, CaseIterable, Identifiable { + case welcome + case overviewSwipe + case overviewCrown + case overviewControlsHide + case overviewControlsShow + case overviewRefresh + case overviewSwitcherTap + case courseListSwipe + case courseListCrown + case dayBrowseSwipe + case dayBrowseCrown + case dayPagingArrow + case dayPagingNext + case dayPagingSwipe + case dayPagingCrown + case dayDatePickerOpen + case dayDatePickerSelect + case weekPagingArrow + case weekPagingNext + case weekPagingSwipe + case weekPagingCrown + case weekCourse + case courseDetailClose + case monthPagingArrow + case monthPagingNext + case monthPagingSwipe + case monthPagingCrown + case monthSelect + case monthExit + /// 实操最后一项练习再次进入引导的长按,成功后衔接组件指南。 + case overviewSwitcherHold + + var id: Int { rawValue } + + var requiredMode: WatchCalendarMode { + switch self { + case .welcome, .overviewSwipe, .overviewCrown, .overviewRefresh, + .overviewControlsHide, .overviewControlsShow, + .overviewSwitcherTap, .overviewSwitcherHold: + .overview + case .courseListSwipe, .courseListCrown: + .courseList + case .dayBrowseSwipe, .dayBrowseCrown, + .dayPagingArrow, .dayPagingNext, + .dayPagingSwipe, .dayPagingCrown, + .dayDatePickerOpen, .dayDatePickerSelect: + .day + case .weekPagingArrow, .weekPagingNext, + .weekPagingSwipe, .weekPagingCrown, + .weekCourse, .courseDetailClose: + .week + case .monthPagingArrow, .monthPagingNext, + .monthPagingSwipe, .monthPagingCrown, + .monthSelect, .monthExit: + .month + } + } + + /// 日期选择步骤需要根视图提前挂载独立月历层。 + var presentsDayDatePicker: Bool { + self == .dayDatePickerSelect + } + + /// 这两步需要让按钮真正随点击隐藏或显示,根视图不能强制覆盖状态。 + var teachesControlVisibility: Bool { + self == .overviewControlsHide || self == .overviewControlsShow + } + + var title: String { + switch self { + case .welcome: + watchLocalizedString("欢迎使用 XDYou") + case .overviewSwipe, .overviewCrown: + onboardingTitle(.overview, action: "浏览课程") + case .overviewControlsHide, .overviewControlsShow: + onboardingTitle(.overview, action: "悬浮按钮") + case .overviewRefresh: + onboardingTitle(.overview, action: "刷新课表") + case .overviewSwitcherTap: + onboardingTitle(.overview, action: "切换视图") + case .overviewSwitcherHold: + onboardingTitle(.overview, action: "重新打开引导") + case .courseListSwipe, .courseListCrown: + onboardingTitle(.courseList, action: "浏览课程") + case .dayBrowseSwipe, .dayBrowseCrown: + onboardingTitle(.day, action: "浏览课程") + case .dayPagingArrow, .dayPagingNext, + .dayPagingSwipe, .dayPagingCrown: + onboardingTitle(.day, action: "翻页") + case .dayDatePickerOpen: + onboardingTitle(.day, action: "打开日期选择器") + case .dayDatePickerSelect: + onboardingTitleText("日期选择器", action: "选择日期") + case .weekPagingArrow, .weekPagingNext, + .weekPagingSwipe, .weekPagingCrown: + onboardingTitle(.week, action: "翻页") + case .weekCourse: + onboardingTitle(.week, action: "查看课程") + case .courseDetailClose: + onboardingTitleText("课程详情", action: "关闭详情") + case .monthPagingArrow, .monthPagingNext, + .monthPagingSwipe, .monthPagingCrown: + onboardingTitle(.month, action: "翻页") + case .monthSelect: + onboardingTitle(.month, action: "选择日期") + case .monthExit: + onboardingTitle(.month, action: "退出") + } + } + + /// 面向第一次使用者的说明只描述当前要执行的动作。 + /// + /// 页面名称由上方标题给出,目标位置由动画指出,因此这里不重复解释实现 + /// 方式、同步阶段或页面定义,避免小屏幕上的说明超过必要长度。 + var message: String { + switch self { + case .welcome: + watchLocalizedString("轻点屏幕以开始") + case .overviewSwipe: + watchLocalizedString("用手指上下滑动屏幕,以浏览当前课程和下一节课程。") + case .courseListSwipe, .dayBrowseSwipe: + watchLocalizedString("用手指上下滑动屏幕,以浏览当前页面中的课程。") + case .overviewCrown: + watchLocalizedString("旋转数码表冠,以浏览当前课程和下一节课程。") + case .courseListCrown, .dayBrowseCrown: + watchLocalizedString("旋转数码表冠,以浏览当前页面中的课程。") + case .overviewControlsHide: + watchLocalizedString("用手指轻点页面空白处,以隐藏右侧操作按钮。") + case .overviewControlsShow: + watchLocalizedString("用手指再次轻点页面空白处,以显示右侧操作按钮。") + case .overviewRefresh: + watchLocalizedString("用手指轻点刷新按钮,以从 iPhone 更新课表。") + case .overviewSwitcherTap: + watchLocalizedString("用手指轻点右下角切换按钮,以打开视图目录。") + case .overviewSwitcherHold: + watchLocalizedString("按住右下角切换按钮三秒,感受逐渐增强的震动。") + case .dayPagingArrow, .weekPagingArrow, .monthPagingArrow: + watchLocalizedString("用手指轻点左侧箭头,以切换到上一页。") + case .dayPagingNext, .weekPagingNext, .monthPagingNext: + watchLocalizedString("用手指轻点右侧箭头,以切换到下一页。") + case .dayPagingSwipe: + watchLocalizedString("用手指左右滑动屏幕,以切换前后日期。") + case .weekPagingSwipe: + watchLocalizedString("用手指左右滑动屏幕,以切换前后周。") + case .monthPagingSwipe: + watchLocalizedString("用手指左右滑动屏幕,以切换前后月份。") + case .dayPagingCrown: + watchLocalizedString("连续旋转数码表冠并越过课程边界,以连续切换日期。") + case .dayDatePickerOpen: + watchLocalizedString("用手指轻点顶部日期标题,以打开日期选择器。") + case .dayDatePickerSelect: + watchLocalizedString("用手指轻点一个日期,以切换到该日期的日视图。") + case .weekPagingCrown: + watchLocalizedString("旋转数码表冠,以连续切换前后周。") + case .monthPagingCrown: + watchLocalizedString("旋转数码表冠,以连续切换前后月份。") + case .weekCourse: + watchLocalizedString("用手指轻点高亮的课程色块,以打开课程详情。") + case .courseDetailClose: + watchLocalizedString("用手指轻点关闭按钮,以返回周视图。") + case .monthSelect: + watchLocalizedString("用手指轻点一个日期,以打开该日期的日视图。") + case .monthExit: + watchLocalizedString("用手指轻点顶部月份标题,以退出月视图。") + } + } + + /// 使用目录中的本地化视图名拼出统一的“视图·任务”教学标题。 + private func onboardingTitle( + _ mode: WatchCalendarMode, + action: String + ) -> String { + "\(mode.title)·\(watchLocalizedString(action))" + } + + /// 非目录页面(日期选择器、课程详情)使用相同标题格式。 + private func onboardingTitleText( + _ page: String, + action: String + ) -> String { + "\(watchLocalizedString(page))·\(watchLocalizedString(action))" + } + + /// 每个教学步骤只验证一个动作;步骤推进后再配置下一项。 + var operation: WatchOnboardingOperation { + switch self { + case .welcome: + .tap(.anywhere) + case .overviewSwipe, .courseListSwipe, .dayBrowseSwipe: + .verticalSwipe + case .overviewCrown, .courseListCrown, .dayBrowseCrown, + .weekPagingCrown, .monthPagingCrown: + .crown + case .overviewControlsHide, .overviewControlsShow: + .tap(.content) + case .overviewRefresh: + .tap(.refresh) + case .overviewSwitcherTap: + .tap(.mode) + case .overviewSwitcherHold: + .longPress(.mode) + case .dayPagingArrow, .weekPagingArrow, .monthPagingArrow: + .tap(.headerPrevious) + case .dayPagingNext, .weekPagingNext, .monthPagingNext: + .tap(.headerNext) + case .dayPagingSwipe, .weekPagingSwipe, .monthPagingSwipe: + .horizontalSwipe + case .dayPagingCrown: + .crownPage + case .dayDatePickerOpen: + .tap(.headerTitle) + case .dayDatePickerSelect, .monthSelect: + .tap(.calendarDate) + case .weekCourse: + .tap(.weekCourse) + case .courseDetailClose: + .tap(.detailClose) + case .monthExit: + .tap(.monthTitle) + } + } +} + +/// 成功或错误时才会短暂出现在表盘中央的结果状态。 +enum WatchOnboardingFeedback: Equatable { + case success + case error +} + +/// 旁路接收真实页面产生的输入并判定当前教学步骤。 +/// +/// 这个对象不持有任何触摸层或表冠焦点,因此不会吞掉底层按钮、滚动和分页 +/// 动画。根视图把真实输入抄送进来;这里仅做类型/位置校验、触觉反馈和切步。 +@MainActor +final class WatchOnboardingInputBridge: ObservableObject { + @Published private(set) var feedback: WatchOnboardingFeedback? + @Published private(set) var showsPrompt = false + + private var step: WatchOnboardingStep? + private var isEvaluating = false + private var feedbackTask: Task? + private var operationAccepted: (( + WatchOnboardingStep, + WatchOnboardingOperation + ) -> Void)? + private var operationRejected: (( + WatchOnboardingStep, + WatchOnboardingOperation + ) -> Void)? + private var advance: (() -> Void)? + + var acceptsOperations: Bool { + step != nil && !isEvaluating + } + + /// 切换步骤前取消上一轮反馈;回调始终绑定当前这次教学配置。 + func configure( + step: WatchOnboardingStep, + operationAccepted: @escaping ( + WatchOnboardingStep, + WatchOnboardingOperation + ) -> Void, + operationRejected: @escaping ( + WatchOnboardingStep, + WatchOnboardingOperation + ) -> Void, + advance: @escaping () -> Void + ) { + cancelFeedbackTask() + self.step = step + self.operationAccepted = operationAccepted + self.operationRejected = operationRejected + self.advance = advance + isEvaluating = false + feedback = nil + presentPrompt() + } + + /// 退出引导时同时释放任务与页面回调,避免继续持有根视图状态。 + func clear() { + cancelFeedbackTask() + step = nil + operationAccepted = nil + operationRejected = nil + advance = nil + isEvaluating = false + feedback = nil + showsPrompt = false + } + + /// 用户真正开始触摸或旋转表冠时才隐去说明遮罩。 + /// + /// 说明没有固定超时:用户可以任意停留阅读;而遮罩消失只是 + /// 视觉状态变更,不会改写底层真实页面的手势或表冠焦点。 + func beginOperation() { + guard step != nil, !isEvaluating, showsPrompt else { return } + withAnimation(WatchOnboardingMotion.promptDismiss) { + showsPrompt = false + } + } + + /// 输入已经停止、但尚未完成当前要求时重新展示说明。 + /// + /// 典型场景是日视图“连续旋转表冠翻页”:用户只转动了几个刻度,没有 + /// 真正跨过日期页。此时不判错,也不能让说明永久透明;表冠空闲后恢复 + /// 提示,用户可以从当前真实页面状态继续尝试。 + func restorePromptAfterIncompleteOperation() { + guard step != nil, + !isEvaluating, + feedback == nil, + !showsPrompt + else { return } + presentPrompt() + } + + /// 操作类型及语义目标都必须匹配;点击还需通过实际命中坐标校验。 + func observe( + _ operation: WatchOnboardingOperation, + at location: CGPoint? = nil, + controlCenters: WatchOnboardingControlCenters? = nil, + in size: CGSize + ) { + guard let step, !isEvaluating + else { return } + let expectedOperation = step.operation + + beginOperation() + + guard expectedOperation == operation, + tapLocationMatches( + expected: expectedOperation, + location: location, + controlCenters: controlCenters, + size: size + ) + else { + operationRejected?(step, operation) + showError() + return + } + + isEvaluating = true + showsPrompt = false + + // 点击会直接改变真实页面(打开目录、翻页、进入详情等)。若立即 + // 黑屏显示对号,用户看不到刚完成的变化;短暂留出透明观察窗口后 + // 再确认成功。滑动和表冠没有这段额外等待,保持即时反馈。 + if expectedOperation.isTapInteraction { + let delay = step == .overviewSwitcherTap + ? WatchOnboardingMotion.overviewExitTapResultDelay + : WatchOnboardingMotion.tapResultDelay + cancelFeedbackTask() + feedbackTask = makeWatchAutoDismissTask( + after: delay + ) { [weak self] in + guard let self, + self.step == step, + self.isEvaluating + else { return } + self.showSuccess( + step: step, + operation: expectedOperation + ) + } + return + } + + showSuccess(step: step, operation: expectedOperation) + } + + /// 正确结果的绘制、触觉、业务收尾和自动推进统一从这里执行。 + private func showSuccess( + step: WatchOnboardingStep, + operation: WatchOnboardingOperation + ) { + withAnimation(WatchOnboardingMotion.feedback) { + feedback = .success + } + WatchHaptics.onboardingSuccess() + operationAccepted?(step, operation) + + cancelFeedbackTask() + let visibleDuration: TimeInterval + switch step { + case .overviewSwitcherTap: + visibleDuration = WatchOnboardingMotion.overviewExitSuccessVisibleDuration + case .overviewSwitcherHold: + visibleDuration = WatchOnboardingMotion.holdSuccessVisibleDuration + default: + visibleDuration = WatchOnboardingMotion.successVisibleDuration + } + feedbackTask = makeWatchAutoDismissTask( + after: visibleDuration + ) { [weak self] in + guard let self else { return } + withAnimation(WatchOnboardingMotion.feedbackDismiss) { + self.feedback = nil + } + self.advance?() + } + } + + private func showError() { + isEvaluating = true + // 错误反馈只叠加白色错号;教学提示和动作示意在下层持续可见。 + withAnimation(WatchOnboardingMotion.feedback) { + showsPrompt = true + feedback = .error + } + WatchHaptics.onboardingError() + cancelFeedbackTask() + feedbackTask = makeWatchAutoDismissTask( + after: WatchOnboardingMotion.errorVisibleDuration + ) { [weak self] in + guard let self else { return } + withAnimation(WatchOnboardingMotion.feedbackDismiss) { + self.feedback = nil + } + self.isEvaluating = false + } + } + + /// 每一项的说明持续显示,直到收到该项的第一个真实输入。 + private func presentPrompt() { + withAnimation(WatchOnboardingMotion.prompt) { + showsPrompt = true + } + } + + private func cancelFeedbackTask() { + feedbackTask?.cancel() + feedbackTask = nil + } + + deinit { + feedbackTask?.cancel() + } + + private func tapLocationMatches( + expected: WatchOnboardingOperation, + location: CGPoint?, + controlCenters: WatchOnboardingControlCenters?, + size: CGSize + ) -> Bool { + let target: WatchOnboardingTapTarget + switch expected { + case let .tap(value), let .longPress(value): + target = value + default: + return true + } + guard let location else { return false } + if target == .anywhere || target == .content { + return true + } + let point = target.point( + in: size, + controlCenters: controlCenters + ) + return hypot(location.x - point.x, location.y - point.y) + <= target.hitRadius + } +} + +private extension WatchOnboardingOperation { + /// 普通点击和长按都会立即改变底层界面,二者共享短暂观察延迟。 + var isTapInteraction: Bool { + switch self { + case .tap, .longPress: + true + default: + false + } + } +} + +/// 只负责显示的全屏引导层;欢迎页和分段页会接收继续轻点, +/// 其余实操步骤不参与命中,让输入直接抵达真实页面。 +struct WatchOnboardingOverlay: View { + let step: WatchOnboardingStep + let sectionIntro: WatchOnboardingSection? + let sectionPreparation: WatchOnboardingPreparationState + let controlCenters: WatchOnboardingControlCenters + let feedback: WatchOnboardingFeedback? + let showsPrompt: Bool + let isInitialPreparationReady: Bool + /// 欢迎页背后的第一段黑场与首个操作提示已完成首轮渲染。 + let initialPresentationPrepared: () -> Void + let start: () -> Void + let openWidgetTutorial: () -> Void + let continueSectionIntro: () -> Void + @State private var animatedCompletedSteps: CGFloat = 0 + + var body: some View { + GeometryReader { proxy in + ZStack { + if let sectionIntro { + ZStack { + // 在纯黑章节页背后提前建立下一项提示使用的玻璃、 + // Canvas 和动作动画资源。实体表第一次创建这些渲染 + // 节点的开销不再落在用户轻点“概览”之后;0.001 只为 + // 防止系统把整棵不可见子树直接裁掉,前方纯黑页会将 + // 它完全遮住,不改变章节页显示。 + onboardingPromptPrewarm( + for: step, + in: proxy.size + ) + .opacity(0.001) + .allowsHitTesting(false) + + WatchOnboardingSectionIntroView( + section: sectionIntro, + preparation: sectionPreparation, + continueAction: continueSectionIntro + ) + } + // 章节页必须先于底层路由变化完整盖住表盘。插入不做 + // 淡入,退出仍保留系统式淡出,杜绝首帧漏出页面切换。 + .transition( + .asymmetric(insertion: .identity, removal: .opacity) + ) + } else if step == .welcome, showsPrompt, feedback == nil { + ZStack { + // 欢迎页的纯黑背景后提前创建第一段黑场和第一个实操 + // 提示。实体表首次建立扫光、玻璃和 Canvas 管线的 + // 开销发生在“正在加载”期间,而不是用户轻点之后。 + initialOnboardingPresentationWarmup(in: proxy.size) + + // 欢迎页接收继续轻点和指南长按;实操遮罩不参与命中, + // 让输入直接抵达真实课表。 + WatchOnboardingWelcomeView( + isReady: isInitialPreparationReady, + start: start, + openWidgetTutorial: openWidgetTutorial + ) + } + .transition(.opacity) + } else { + ZStack { + Group { + if showsPrompt { + // 教学出现时稍微压低真实页面亮度,把注意力集中在 + // 操作目标;整层连续透明,不制造横向分界线。 + Color.black.opacity(0.32) + .ignoresSafeArea() + .transition(.opacity) + .zIndex(0) + + stepTitleBanner + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .top + ) + // 位于系统状态栏下方,不侵占底部说明区。 + .padding(.horizontal, WatchOnboardingOverlayLayout.horizontalInset) + .padding(.top, WatchOnboardingOverlayLayout.titleTopInset) + .zIndex(10) + + WatchOnboardingOperationCue( + operation: step.operation, + viewportSize: proxy.size, + controlCenters: controlCenters + ) + .id(step.rawValue) + // 点击、滑动和表冠示范始终覆盖标题及底部液态玻璃, + // 避免目标靠近说明区时被材质截断或遮暗。 + .zIndex(100) + + instruction + // 提示按自己的自然高度贴底;下三分之一只是 + // 最大可用区域,并不会被空白框强制占满。 + .frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .bottom + ) + .padding(.horizontal, WatchOnboardingOverlayLayout.horizontalInset) + .padding(.bottom, WatchOnboardingOverlayLayout.instructionBottomInset) + .zIndex(10) + } + + if let feedback { + // 正确时完全黑屏确认;错误时保留真实页面,仅把白色 + // 错号和操作提示叠在最上层,便于立即对照重试。 + if feedback == .success { + Color.black + .ignoresSafeArea() + } + + WatchOnboardingResultAnimation( + feedback: feedback + ) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .center + ) + .transition(.opacity) + // 正确/错误反馈属于教学动作的最终状态,覆盖其余 + // 教学内容,但不会参与真实页面命中。 + .zIndex(200) + } + } + .allowsHitTesting(false) + + } + } + } + } + // 根导航容器会把普通内容放到状态栏下方;教学层跨越安全区后, + // 标题与底部说明才能按整个表盘的固定参考位置摆放。 + .ignoresSafeArea() + .accessibilityElement(children: .contain) + .onAppear { + animateProgress(to: step.rawValue + 1) + } + .onChange(of: step.rawValue) { _, rawValue in + animateProgress(to: rawValue + 1) + } + } + + /// 章节黑场期间仅预热下一项会使用的提示组件,不显示也不接收输入。 + /// + /// 这里保持与实操层相同的尺寸、材质和动作类型,使真机提前完成首轮 + /// 字形、玻璃和 Canvas 管线准备;章节淡出后不再集中创建这些节点。 + private func onboardingPromptPrewarm( + for warmupStep: WatchOnboardingStep, + in viewportSize: CGSize + ) -> some View { + ZStack { + stepTitleBanner(for: warmupStep) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .top + ) + .padding(.horizontal, WatchOnboardingOverlayLayout.horizontalInset) + .padding(.top, WatchOnboardingOverlayLayout.titleTopInset) + + WatchOnboardingOperationCue( + operation: warmupStep.operation, + viewportSize: viewportSize, + controlCenters: controlCenters + ) + + instruction(for: warmupStep) + .frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .bottom + ) + .padding(.horizontal, WatchOnboardingOverlayLayout.horizontalInset) + .padding(.bottom, WatchOnboardingOverlayLayout.instructionBottomInset) + } + // 预热只需提交首帧材质与字形;黑场背后的提示不应持续运行动画。 + .environment(\.watchOnboardingAnimationsPaused, true) + } + + /// 欢迎页背后的真实首屏预热内容。 + /// + /// 两次让出主线程后再等待一个短帧窗口,确保 SwiftUI 不仅建立了 View + /// 值,还至少提交过一轮材质与 Canvas。完成回调和数据准备共同控制欢迎 + /// 页是否允许轻点,用户进入后不再承担首次渲染开销。 + private func initialOnboardingPresentationWarmup( + in viewportSize: CGSize + ) -> some View { + ZStack { + onboardingPromptPrewarm( + for: .overviewSwipe, + in: viewportSize + ) + + WatchOnboardingSectionIntroView( + section: .overview, + preparation: .ready, + continueAction: {} + ) + } + .opacity(0.001) + .allowsHitTesting(false) + .environment(\.watchOnboardingAnimationsPaused, true) + .task { + await Task.yield() + await Task.yield() + do { + try await Task.sleep(nanoseconds: 120_000_000) + } catch { + return + } + guard !Task.isCancelled else { return } + initialPresentationPrepared() + } + } + + /// 文字按实际内容高度贴近屏幕底部;不会强占整个下三分之一。 + private var instruction: some View { + instruction(for: step) + } + + /// 图标固定在左侧垂直居中,正文最多两行并适度放大。整个提示只占用 + /// 自然高度,避免遮住本来要操作的页面内容。 + @ViewBuilder + private func instruction( + for instructionStep: WatchOnboardingStep + ) -> some View { + let content = HStack(alignment: .center, spacing: 7) { + Image(systemName: instructionSystemImage( + for: instructionStep.operation + )) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white.opacity(0.96)) + .frame(width: 20, alignment: .center) + + Text(verbatim: instructionStep.message) + .font(.caption.weight(.medium)) + .foregroundStyle(.white) + .lineLimit(2) + .minimumScaleFactor(0.68) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 9) + .padding(.vertical, 6) + .frame(maxWidth: .infinity) + + if #available(watchOS 26.0, *) { + content + .glassEffect(.regular, in: RoundedRectangle( + cornerRadius: WatchOnboardingOverlayLayout.panelCornerRadius, + style: .continuous + )) + } else { + content + .background( + .ultraThinMaterial, + in: RoundedRectangle( + cornerRadius: WatchOnboardingOverlayLayout.panelCornerRadius, + style: .continuous + ) + ) + } + } + + /// 底部说明图标只表达输入方式,不重复页面图标。 + private func instructionSystemImage( + for operation: WatchOnboardingOperation + ) -> String { + switch operation { + case .tap: + "hand.tap.fill" + case .longPress: + "hand.point.up.left.fill" + case .verticalSwipe, .horizontalSwipe: + "hand.draw.fill" + case .crown, .crownPage: + "digitalcrown.horizontal.arrow.counterclockwise" + } + } + + /// 所有步骤使用相同宽度的标题;连续进度融入标题材质,不占额外布局高度。 + private var stepTitleBanner: some View { + stepTitleBanner(for: step) + } + + @ViewBuilder + private func stepTitleBanner( + for bannerStep: WatchOnboardingStep + ) -> some View { + let content = ZStack { + stepTitleMaterial + + Text(verbatim: bannerTitle(for: bannerStep)) + .font(.caption.weight(.semibold)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.68) + .padding(.horizontal, 9) + } + .frame(maxWidth: .infinity) + .frame(height: WatchOnboardingOverlayLayout.titleHeight) + + if #available(watchOS 26.0, *) { + content + .glassEffect(.regular, in: RoundedRectangle( + cornerRadius: WatchOnboardingOverlayLayout.panelCornerRadius, + style: .continuous + )) + .glassEffectTransition(.materialize) + } else { + content.background( + .ultraThinMaterial, + in: RoundedRectangle( + cornerRadius: WatchOnboardingOverlayLayout.panelCornerRadius, + style: .continuous + ) + ) + } + } + + /// 标题材质只保留暗色底和单条连续蓝色进度。 + /// + /// 去掉持续刷新的斑驳 Canvas 后,实体表在展示操作动画时无需额外进行 + /// 15 fps 的异步绘制,标题轮廓也能始终和底部说明严格对齐。 + private var stepTitleMaterial: some View { + GeometryReader { proxy in + ZStack(alignment: .leading) { + RoundedRectangle( + cornerRadius: WatchOnboardingOverlayLayout.panelCornerRadius, + style: .continuous + ) + .fill(Color.white.opacity(0.08)) + + LinearGradient( + colors: [ + Color.blue.opacity(0.34), + Color.cyan.opacity(0.22), + ], + startPoint: .leading, + endPoint: .trailing + ) + // 渐变始终保持和外层玻璃相同的完整尺寸,再用矩形遮罩 + // 表示完成比例。不能先缩窄再裁成 Capsule,否则进度较少 + // 时会得到一个独立“小胶囊”,轮廓无法与标题玻璃重合。 + .frame(width: proxy.size.width, height: proxy.size.height) + .mask(alignment: .leading) { + Rectangle() + .frame( + width: proxy.size.width * overallProgress, + height: proxy.size.height + ) + } + .clipShape(RoundedRectangle( + cornerRadius: WatchOnboardingOverlayLayout.panelCornerRadius, + style: .continuous + )) + } + } + } + + /// 欢迎页不显示普通教学标题;其余步骤使用从 1 开始的教学序号。 + private func bannerTitle(for bannerStep: WatchOnboardingStep) -> String { + let total = max(1, WatchOnboardingStep.allCases.count - 1) + let index = min(total, max(1, bannerStep.rawValue)) + return "\(index)/\(total) \(bannerStep.title)" + } + + /// 所有教学步骤共用一条 0...1 连续进度。 + private var overallProgress: CGFloat { + min( + 1, + max( + 0, + animatedCompletedSteps + / CGFloat(WatchOnboardingStep.allCases.count) + ) + ) + } + + private func animateProgress(to completedSteps: Int) { + withAnimation(WatchOnboardingMotion.progress) { + animatedCompletedSteps = CGFloat(completedSteps) + } + } + +} + +/// 与欢迎页一致的纯黑分段提示页。 +/// +/// 该页面在存在期间主动覆盖并拦截底层输入;根视图会等淡出结束后才配置下一 +/// 项教学检测,因此用户在过场期间触摸或转动表冠都不会被误判为已完成操作。 +private struct WatchOnboardingSectionIntroView: View { + let section: WatchOnboardingSection + let preparation: WatchOnboardingPreparationState + let continueAction: () -> Void + @State private var contentVisible = false + + var body: some View { + Button { + guard preparation == .ready else { return } + continueAction() + } label: { + ZStack { + Color.black + .ignoresSafeArea() + + VStack(spacing: 18) { + WatchOnboardingSweepingLightText( + text: section.title, + font: .headline.weight(.semibold), + baseOpacity: 1 + ) + preparationMessage + } + .multilineTextAlignment(.center) + .padding(.horizontal, 22) + .opacity(contentVisible ? 1 : 0) + .scaleEffect(contentVisible ? 1 : 0.96) + + WatchOnboardingSectionProgressView( + stage: section.progressStage + ) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .top + ) + .padding(.top, 30) + .opacity(contentVisible ? 1 : 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onAppear { + withAnimation(WatchOnboardingMotion.sectionIntro) { + contentVisible = true + } + } + } + + @ViewBuilder + private var preparationMessage: some View { + switch preparation { + case .ready: + WatchOnboardingSweepingLightText( + text: watchLocalizedString("轻点以继续"), + font: .caption2.weight(.medium), + baseOpacity: 0.76 + ) + case .loading: + VStack(spacing: 7) { + ProgressView() + .controlSize(.small) + .tint(.white) + Text(verbatim: watchLocalizedString( + "第一次载入课表,正在渲染底层数据" + )) + .font(.caption2.weight(.medium)) + .foregroundStyle(.white.opacity(0.82)) + .multilineTextAlignment(.center) + } + case .completed: + VStack(spacing: 7) { + Image(systemName: "checkmark.circle.fill") + .font(.title3) + .foregroundStyle(.green) + Text(verbatim: watchLocalizedString("底层数据已准备完成")) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.white) + } + } + } +} + +/// 五个视图章节共用的阶段进度。 +/// +/// `n/5` 只统计真实功能视图;欢迎页和最终完成页不显示章节进度。进入 +/// 第五段时只填满最后一格,五段之间始终留有间隔。 +private struct WatchOnboardingSectionProgressView: View { + let stage: Int + @State private var currentSegmentFill: CGFloat = 0 + + private let segmentCount = 5 + private let barWidth: CGFloat = 112 + private let barHeight: CGFloat = 5 + private let segmentSpacing: CGFloat = 4 + + var body: some View { + VStack(spacing: 5) { + Text(verbatim: "\(min(5, max(1, stage)))/5") + .font(.caption2.weight(.semibold)) + .monospacedDigit() + .foregroundStyle(.white.opacity(0.88)) + + HStack(spacing: segmentSpacing) { + ForEach(0.. some View { + let activeIndex = stage - 1 + let completed = index < activeIndex + let isCurrent = index == activeIndex + let fill = completed ? 1 : (isCurrent ? currentSegmentFill : 0) + let segmentWidth = ( + barWidth - CGFloat(segmentCount - 1) * segmentSpacing + ) / CGFloat(segmentCount) + + return Capsule() + .fill(Color.white.opacity(0.15)) + .overlay(alignment: .leading) { + Rectangle() + .fill( + LinearGradient( + colors: [Color.blue, Color.cyan.opacity(0.88)], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: segmentWidth * fill) + } + .clipShape(Capsule()) + .frame(width: segmentWidth, height: barHeight) + } + + private func startAnimation() { + withAnimation(.easeOut(duration: 0.52)) { + currentSegmentFill = 1 + } + } +} + +/// 直接画在真实操作位置上的视觉示范。 +/// +/// 滑动提示位于屏幕中央,点击/长按使用语义目标的真实坐标,表冠提示根据 +/// 系统表冠方向贴近左侧或右侧实体表冠。它只负责绘制且由父层禁用命中, +/// 不会抢走底层输入。 +private struct WatchOnboardingOperationCue: View { + @Environment(\.watchOnboardingAnimationsPaused) private var animationsPaused + let operation: WatchOnboardingOperation + let viewportSize: CGSize + let controlCenters: WatchOnboardingControlCenters + /// 点击步骤只在首个有效实测坐标到达时锁定一次;页面后续布局采样不会 + /// 再驱动 `.position`,因此提示不会从回退点缓慢漂向实际按钮。 + @State private var lockedTapPoint: CGPoint? = nil + + @ViewBuilder + var body: some View { + switch operation { + case let .tap(target): + tapAnimation(target: target, holds: false) + case let .longPress(target): + tapAnimation(target: target, holds: true) + case .verticalSwipe: + phaseAnimation { phase in + swipeCue(axis: .vertical, phase: phase) + } + case .horizontalSwipe: + phaseAnimation { phase in + swipeCue(axis: .horizontal, phase: phase) + } + case .crown: + phaseAnimation { phase in + crownCue(phase: phase, showsPagingHint: false) + } + case .crownPage: + phaseAnimation { phase in + crownCue(phase: phase, showsPagingHint: true) + } + } + } + + @ViewBuilder + private func phaseAnimation( + @ViewBuilder content: @escaping (Bool) -> Content + ) -> some View { + PhaseAnimator(animationsPaused ? [false] : [false, true]) { phase in + content(phase) + .compositingGroup() + } animation: { _ in + WatchOnboardingMotion.operationHint + } + } + + @ViewBuilder + private func tapAnimation( + target: WatchOnboardingTapTarget, + holds: Bool + ) -> some View { + Group { + if let lockedTapPoint { + PhaseAnimator(animationsPaused ? [false] : [false, true]) { phase in + tapCue(holds: holds, phase: phase) + } animation: { _ in + WatchOnboardingMotion.operationHint + } + // 坐标位于循环动画之外,只在第一次得到真实几何时直接安装; + // PhaseAnimator 此后只改变圆环和手指大小,绝不会插值位置。 + .position(lockedTapPoint) + .compositingGroup() + } else { + Color.clear + } + } + // Toolbar 和周网格可能晚一帧上报。每次中心集合变化时只尝试 + // 补齐尚未锁定的位置,已经显示的提示保持不动。 + .onAppear { lockTapPointIfPossible(target) } + .onChange(of: controlCenters) { _, _ in + lockTapPointIfPossible(target) + } + } + + /// 点击目标使用扩散圆环和手指图标;长按额外保留中心实心光点。 + private func tapCue( + holds: Bool, + phase: Bool + ) -> some View { + ZStack { + Circle() + .stroke(Color.white.opacity(phase ? 0.08 : 0.82), lineWidth: 2) + .frame(width: 35, height: 35) + .scaleEffect(phase ? 1.35 : 0.62) + + if holds { + Circle() + .fill(Color.white.opacity(phase ? 0.72 : 0.24)) + .frame(width: 10, height: 10) + .scaleEffect(phase ? 0.78 : 1.25) + } + + Image(systemName: holds ? "hand.point.up.left.fill" : "hand.tap.fill") + .font(.system(size: 19, weight: .medium)) + .foregroundStyle(.white) + // 手指与扩散圆环共用相位;缩放以真实点击位置为中心。 + .scaleEffect(phase ? 1.12 : 0.82) + .offset(y: phase ? -2 : 2) + } + } + + private func lockTapPointIfPossible( + _ target: WatchOnboardingTapTarget + ) { + guard lockedTapPoint == nil, + let point = target.cuePoint( + in: viewportSize, + controlCenters: controlCenters + ) + else { return } + // 不包裹 withAnimation:提示第一次出现时直接位于目标圆心。 + lockedTapPoint = point + } + + /// 横向或纵向手势使用同一组系统手形,只改变运动轴。 + private func swipeCue( + axis: CalendarPagingDragAxis, + phase: Bool + ) -> some View { + ZStack { + Image( + systemName: axis == .horizontal + ? "arrow.left.and.right" + : "arrow.up.and.down" + ) + .font(.system(size: 30, weight: .light)) + .foregroundStyle(.white.opacity(0.68)) + + Image(systemName: "hand.draw.fill") + .font(.system(size: 25, weight: .medium)) + .foregroundStyle(.white) + .offset( + x: axis == .horizontal ? (phase ? 17 : -17) : 0, + y: axis == .vertical ? (phase ? 17 : -17) : 0 + ) + } + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .center + ) + } + + /// 手指贴着实体表冠上下拨动,刻纹同步滚动,表达“用手指旋转表冠”。 + /// 这里只动画几何位移,不使用模糊或阴影,避免教学循环掉帧。 + private func crownCue( + phase: Bool, + showsPagingHint: Bool + ) -> some View { + let cueHeight: CGFloat = 70 + let rightEdgeInset: CGFloat = 1 + let verticalTravel: CGFloat = phase ? 9 : -9 + let crownCenterY = min( + viewportSize.height - cueHeight * 0.5, + max( + cueHeight * 0.5, + viewportSize.height + * WatchOnboardingOverlayLayout.crownCenterHeightRatio + ) + ) + + return ZStack(alignment: .topTrailing) { + Color.clear + + // 三个可见组件组成紧凑 HStack;末尾表冠胶囊是动画的真实右边界。 + HStack(spacing: 0) { + Image( + systemName: showsPagingHint + ? "arrow.left.and.right" + : "arrow.up.and.down" + ) + .font(.system(size: 31, weight: .light)) + .foregroundStyle(.white.opacity(0.36)) + .frame(width: 31) + + Image(systemName: "hand.point.up.left.fill") + .font(.system(size: 24, weight: .medium)) + .foregroundStyle(.white) + .rotationEffect(.degrees(134)) + .frame(width: 28, height: 34) + // 指尖略微进入表冠左缘,纵向运动时始终保持接触。 + .offset(x: 4, y: verticalTravel) + + ZStack { + Capsule() + .fill(Color.black.opacity(0.42)) + Capsule() + .stroke(Color.white.opacity(0.92), lineWidth: 1.5) + + VStack(spacing: 3) { + ForEach(0..<9, id: \.self) { _ in + Capsule() + .fill(Color.white.opacity(0.78)) + .frame(width: 6, height: 1) + } + } + // 刻纹与手指同向移动,表现手指正在拨动实体表冠。 + .offset(y: verticalTravel * 0.55) + .mask(Capsule()) + } + .frame(width: 12, height: 43) + } + .fixedSize() + .frame(height: cueHeight) + // padding 属于可见组合外缘:表冠胶囊距屏幕右侧恰好 1pt, + // SF Symbol 自带的透明字形边距不参与贴边计算。 + .padding(.trailing, rightEdgeInset) + .offset(y: crownCenterY - cueHeight * 0.5) + } + .frame(width: viewportSize.width, height: viewportSize.height) + } +} + +/// 纯黑欢迎页。标题位于表盘几何中心,底部斜向柔光文字提示用户轻点开始。 +private struct WatchOnboardingWelcomeView: View { + let isReady: Bool + let start: () -> Void + let openWidgetTutorial: () -> Void + @Environment(\.scenePhase) private var scenePhase + @State private var titleVisible = false + @State private var promptVisible = false + @State private var press = WatchPressSession() + @GestureState private var pressGestureIsActive = false + @State private var pressStartedAt: ContinuousClock.Instant? + @State private var holdTask: Task? + @State private var holdFeedback: WatchHoldFeedbackPulse? + @State private var didCompleteHold = false + + private var isHolding: Bool { + scenePhase == .active && press.isActive && !didCompleteHold + } + + var body: some View { + // 与模式按钮相同,由一套按压手势提交点击或长按,防止松手时补发开始操作。 + Button(action: {}) { + ZStack { + Color.black.ignoresSafeArea() + + VStack(spacing: 6) { + WatchOnboardingSweepingLightText( + text: watchLocalizedString("欢迎使用 XDYou"), + font: .title3.weight(.semibold), + baseOpacity: 1 + ) + WatchOnboardingSweepingLightText( + text: watchLocalizedString("Apple Watch 课表"), + font: .caption, + baseOpacity: 0.72 + ) + } + .opacity(titleVisible ? 1 : 0) + .scaleEffect(titleVisible ? 1 : 0.94) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .center + ) + + Group { + if isReady { + WatchOnboardingSweepingLightText( + text: watchLocalizedString("轻点屏幕以开始"), + font: .caption.weight(.semibold), + baseOpacity: 1 + ) + .transition(.opacity) + } else { + VStack(spacing: 7) { + Text(verbatim: watchLocalizedString( + "正在加载新手引导" + )) + .font(.caption2.weight(.medium)) + .foregroundStyle(.white.opacity(0.88)) + + // 预热任务没有稳定的分项百分比,使用白色不定量 + // 进度条表达“仍在工作”,避免伪造数值进度。 + WatchOnboardingLoadingBar() + } + .transition(.opacity) + } + } + .opacity(promptVisible ? 1 : 0) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .bottom + ) + .padding(.bottom, 24) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .simultaneousGesture(welcomePressGesture) + .sensoryFeedback(trigger: holdFeedback) { _, pulse in + guard isHolding else { return nil } + return pulse?.feedback + } + .accessibilityAction { startIfReady() } + .accessibilityAction(named: Text(verbatim: watchLocalizedString("小组件使用指南"))) { + openWidgetTutorial() + } + .animation(.easeInOut(duration: 0.24), value: isReady) + .onAppear { + withAnimation(WatchOnboardingMotion.welcome) { + titleVisible = true + } + withAnimation(WatchOnboardingMotion.welcomePrompt.delay(0.12)) { + promptVisible = true + } + } + .onChange(of: isReady) { wasReady, isReady in + // 只在本次预热由未完成变为完成时反馈;命中缓存、欢迎页已经 + // 以 ready 状态创建时不会无缘无故震动。 + guard !wasReady, isReady, !press.isActive else { return } + WatchHaptics.onboardingSuccess() + } + .onChange(of: pressGestureIsActive) { _, isActive in + // 系统取消手势时没有 onEnded,仍需停止震动,且不能补成一次点击。 + if !isActive, press.isActive || press.isCancelled { + resetWelcomePress() + } + } + .onChange(of: scenePhase) { _, phase in + if phase != .active { cancelWelcomePress() } + } + .onDisappear(perform: resetWelcomePress) + } + + private var welcomePressGesture: some Gesture { + DragGesture(minimumDistance: 0, coordinateSpace: .local) + .updating($pressGestureIsActive) { _, active, _ in active = true } + .onChanged { value in + guard hypot(value.translation.width, value.translation.height) <= 36 else { + cancelWelcomePress() + return + } + beginWelcomePressIfNeeded() + } + .onEnded { _ in finishWelcomePress() } + } + + private func beginWelcomePressIfNeeded() { + guard scenePhase == .active, press.begin() else { return } + didCompleteHold = false + cancelHoldFeedback() + pressStartedAt = ContinuousClock().now + // 组件图片不依赖实操预热,欢迎页仍在加载时也允许使用长按入口。 + holdTask = makeWatchHoldFeedbackTask( + isActive: { isHolding }, + onPulse: { holdFeedback = $0 }, + onComplete: completeWelcomeHold + ) + } + + private func finishWelcomePress() { + let elapsed = pressStartedAt.map { $0.duration(to: ContinuousClock().now) } + // 主线程忙时计时回调可能晚到;实际按满三秒的松手仍只提交一次长按。 + if let elapsed, elapsed >= WatchHoldFeedbackPulse.holdDuration { + completeWelcomeHold() + } + let isTap = elapsed.map { $0 < WatchHoldFeedbackPulse.startDelay } ?? false + let shouldTap = press.finish(didTriggerLongPress: didCompleteHold || !isTap) + resetWelcomePress() + if shouldTap { startIfReady() } + } + + private func completeWelcomeHold() { + guard isHolding else { return } + didCompleteHold = true + cancelHoldFeedback() + openWidgetTutorial() + } + + private func startIfReady() { + guard scenePhase == .active, isReady else { return } + start() + } + + private func cancelHoldFeedback() { + holdTask?.cancel() + holdTask = nil + holdFeedback = nil + } + + private func cancelWelcomePress() { + press.cancel() + pressStartedAt = nil + cancelHoldFeedback() + } + + private func resetWelcomePress() { + cancelHoldFeedback() + press.reset() + pressStartedAt = nil + didCompleteHold = false + } +} + +/// 欢迎页使用的白色不定量进度条。 +/// +/// 只移动一个固定宽度的高亮段,渲染开销远低于复杂 Canvas,同时不会显示 +/// 没有真实依据的百分比。往返动画保证等待时间较长时仍能看到持续进展。 +private struct WatchOnboardingLoadingBar: View { + @State private var movesToTrailingEdge = false + + var body: some View { + GeometryReader { proxy in + let segmentWidth = max(18, proxy.size.width * 0.34) + + ZStack(alignment: .leading) { + Capsule() + .fill(Color.white.opacity(0.18)) + + Capsule() + .fill( + LinearGradient( + colors: [ + Color.white.opacity(0.52), + Color.white, + Color.white.opacity(0.52), + ], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: segmentWidth) + .offset( + x: movesToTrailingEdge + ? max(0, proxy.size.width - segmentWidth) + : 0 + ) + } + .clipShape(Capsule()) + } + .frame(width: 92, height: 4) + .onAppear { + withAnimation( + .easeInOut(duration: 0.92) + .repeatForever(autoreverses: true) + ) { + movesToTrailingEdge = true + } + } + .accessibilityHidden(true) + } +} + +/// 纯黑过渡页共用的斜向扫光文字。 +/// +/// 低亮白色保证文字始终可读,较亮的宽柔光带从左下向右上穿过字形。 +/// 扫光进度使用单调的正弦速度修正:运动会自然加速、减速,但不会反向; +/// 循环复位发生在光带完全离开文字以后,因此不会出现可见跳帧。 +struct WatchOnboardingSweepingLightText: View { + @Environment(\.watchOnboardingAnimationsPaused) private var animationsPaused + let text: String + let font: Font + let baseOpacity: Double + + /// 一次扫光的总时长;光带越宽,速度变化越柔和。 + private let sweepDuration: TimeInterval = 4.0 + + var body: some View { + Text(verbatim: text) + .font(font) + .foregroundStyle(.white.opacity(baseOpacity * 0.78)) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .overlay { + TimelineView(.animation(minimumInterval: 1 / 30, paused: animationsPaused)) { timeline in + GeometryReader { proxy in + let width = proxy.size.width + let height = proxy.size.height + let bandWidth = max(48, width * 0.68) + let progress = sweepProgress(at: timeline.date) + let travel = width + bandWidth * 2 + + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init( + color: .white.opacity(baseOpacity * 0.08), + location: 0.18 + ), + .init( + color: .white.opacity(baseOpacity * 0.58), + location: 0.50 + ), + .init( + color: .white.opacity(baseOpacity * 0.08), + location: 0.82 + ), + .init(color: .clear, location: 1), + ], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: bandWidth, height: max(60, height * 3.4)) + .rotationEffect(.degrees(-20)) + .blur(radius: 2.2) + .offset( + x: -bandWidth + travel * progress, + y: -max(20, height * 1.2) + ) + } + } + .mask { + Text(verbatim: text) + .font(font) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } + .accessibilityLabel(Text(verbatim: text)) + } + + /// 返回 0...1 的单向非匀速进度。 + /// + /// 正弦项只改变瞬时速度且幅度小于线性项,确保扫光始终向前运动。 + private func sweepProgress(at date: Date) -> CGFloat { + let elapsed = date.timeIntervalSinceReferenceDate + .truncatingRemainder(dividingBy: sweepDuration) + let linearProgress = elapsed / sweepDuration + let speedVariation = sin(linearProgress * .pi * 2) * 0.045 + return CGFloat(linearProgress - speedVariation) + } +} + +/// 参考系统确认反馈的紧凑“圆环—对号”动画。 +/// +/// 成功绘制圆环与对号,错误只绘制白色错号;两种反馈共用容器,切换时不跳位。 +private struct WatchOnboardingResultAnimation: View { + let feedback: WatchOnboardingFeedback + @State private var ringProgress: CGFloat = 0 + @State private var resultProgress: CGFloat = 0 + @State private var resultScale: CGFloat = 0.82 + + private var resultColor: Color { + feedback == .success ? .green : .white + } + + var body: some View { + resultSymbol + .task(animateResult) + .accessibilityLabel( + feedback == .success + ? watchLocalizedString("操作正确") + : watchLocalizedString("操作错误") + ) + } + + /// 成功和错误共用相同的外部尺寸,只替换内部图形。 + private var resultSymbol: some View { + ZStack { + if feedback == .success { + Circle() + .trim(from: 0, to: ringProgress) + .stroke( + resultColor, + style: StrokeStyle(lineWidth: 3.6, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .shadow(color: resultColor.opacity(0.24), radius: 2.5) + .frame(width: 54, height: 54) + + WatchOnboardingCheckmarkShape() + .trim(from: 0, to: resultProgress) + .stroke( + resultColor, + style: StrokeStyle( + lineWidth: 4.6, + lineCap: .round, + lineJoin: .round + ) + ) + .frame(width: 27, height: 22) + } else { + WatchOnboardingXmarkShape() + .trim(from: 0, to: resultProgress) + .stroke( + resultColor, + style: StrokeStyle( + lineWidth: 5.8, + lineCap: .round, + lineJoin: .round + ) + ) + .frame(width: 37, height: 37) + .shadow(color: resultColor.opacity(0.18), radius: 2) + } + } + .frame(width: 58, height: 58) + .scaleEffect(resultScale) + } + + /// 成功先画圆环再画对号;错误直接画错号,不执行无意义的圆环阶段。 + private func animateResult() async { + if feedback == .success { + withAnimation(WatchOnboardingMotion.ring) { + ringProgress = 1 + resultScale = 1 + } + try? await Task.sleep( + nanoseconds: WatchOnboardingMotion.resultOverlapDelayNanoseconds + ) + guard !Task.isCancelled else { return } + withAnimation(WatchOnboardingMotion.result) { + resultProgress = 1 + } + } else { + withAnimation(WatchOnboardingMotion.result) { + resultProgress = 1 + resultScale = 1 + } + } + } +} + +/// 从左下至右上一笔绘制的对号。 +private struct WatchOnboardingCheckmarkShape: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.minX, y: rect.midY)) + path.addLine( + to: CGPoint(x: rect.minX + rect.width * 0.38, y: rect.maxY) + ) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) + return path + } +} + +/// 两条对角线组成的纯白错号。 +private struct WatchOnboardingXmarkShape: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.minX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + path.move(to: CGPoint(x: rect.maxX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) + return path + } +} diff --git a/watchOS/Views/WeekScheduleView.swift b/watchOS/Views/WeekScheduleView.swift new file mode 100644 index 00000000..67165def --- /dev/null +++ b/watchOS/Views/WeekScheduleView.swift @@ -0,0 +1,921 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import SwiftUI + +/// 一周七列、最多十节的紧凑课表。 +/// +/// 色块点击在网格容器中统一做坐标命中测试,空白点击才会唤回浮动按钮; +/// 因而色块与空白区域始终使用互斥的命中路径。 +struct WeekScheduleView: View { + @EnvironmentObject private var store: WatchScheduleStore + @State private var anchorDate: Date + @Binding var selectedCourse: WatchCourse? + @State private var crownValue = 0.0 + @State private var lastCrownEventOffset = 0.0 + @State private var crownSession = WatchCrownTurnSession() + @State private var crownPageRamp = CalendarCrownPageRamp() + @State private var horizontalPageOffset: CGFloat = 0 + @State private var horizontalTouchStartOffset: CGFloat = 0 + @State private var horizontalPageWidth: CGFloat = 1 + @State private var horizontalCrownVelocity: CGFloat = 0 + @State private var pageTransitionToken = 0 + @State private var pageTransitionTask: Task? + @State private var crownIdleCoordinator = CalendarCrownIdleCoordinator() + @State private var pageTransitionInFlight = false + @State private var weekBoundaryHapticPlayed = false + @State private var restoreCrownFocusTask: Task? + @FocusState private var crownFocused: Bool + let onEmptyTap: () -> Void + let onCrownInteraction: () -> Void + let onCrownInput: () -> Void + let onTouchInputBegan: () -> Void + let onSwipeInput: (CalendarPagingDragAxis) -> Void + let onHeaderPreviousTap: () -> Void + let onHeaderNextTap: () -> Void + let onboardingTargetCourse: WatchCourse? + let onCourseFrameChange: (WatchCourse, CGRect) -> Void + let onCourseSelected: (WatchCourse) -> Void + + init( + selectedCourse: Binding, + initialDate: Date, + onEmptyTap: @escaping () -> Void, + onCrownInteraction: @escaping () -> Void, + onCrownInput: @escaping () -> Void, + onTouchInputBegan: @escaping () -> Void, + onSwipeInput: @escaping (CalendarPagingDragAxis) -> Void, + onHeaderPreviousTap: @escaping () -> Void, + onHeaderNextTap: @escaping () -> Void, + onboardingTargetCourse: WatchCourse?, + onCourseFrameChange: @escaping (WatchCourse, CGRect) -> Void, + onCourseSelected: @escaping (WatchCourse) -> Void + ) { + _selectedCourse = selectedCourse + _anchorDate = State(initialValue: initialDate) + self.onEmptyTap = onEmptyTap + self.onCrownInteraction = onCrownInteraction + self.onCrownInput = onCrownInput + self.onTouchInputBegan = onTouchInputBegan + self.onSwipeInput = onSwipeInput + self.onHeaderPreviousTap = onHeaderPreviousTap + self.onHeaderNextTap = onHeaderNextTap + self.onboardingTargetCourse = onboardingTargetCourse + self.onCourseFrameChange = onCourseFrameChange + self.onCourseSelected = onCourseSelected + } + + /// 当前周的周一零点。 + private var weekStart: Date { + calendarWeekStart(containing: anchorDate) + } + + /// 只保留当前周 `[周一, 下周一)` 内的日程。 + private func courses(in start: Date) -> [WatchCourse] { + store.courses(startingAt: start, dayCount: 7) + } + + var body: some View { + ZStack(alignment: .bottomLeading) { + CalendarHorizontalPager( + pageOffset: horizontalPageOffset, + pageIdentity: weekDate, + page: weekPage, + onViewportWidthChange: { + horizontalPageWidth = max(1, $0) + }, + onViewportHeightChange: { _ in }, + onHorizontalDragBegan: beginWeekHorizontalDrag, + onHorizontalDragChanged: updateWeekHorizontalDrag, + onHorizontalDragEnded: finishWeekHorizontalDrag, + onVerticalDragBegan: {}, + onVerticalDragChanged: { _ in }, + onVerticalDragEnded: { _ in }, + onDragAxisLocked: { _ in onTouchInputBegan() }, + onDragCancelled: { axis in + guard !pageTransitionInFlight else { return } + if axis == .horizontal { settleWeekPage(direction: 0, velocity: 0) } + } + ) + + crownObserver + } + .toolbar { + if selectedCourse == nil { + ToolbarItem(placement: .topBarLeading) { + DateNavigationHeader( + title: weekTitle, + previous: { + onHeaderPreviousTap() + requestWeekPage(-1) + }, + next: { + onHeaderNextTap() + requestWeekPage(1) + } + ) + .frame(width: 116) + .offset(y: -10) + } + } + } + .onAppear { + crownFocused = true + lastCrownEventOffset = crownValue + clampAnchorToSemester() + } + .onChange(of: store.semesterRangeStart) { _, _ in + clampAnchorToSemester() + } + .onChange(of: store.semesterRangeEnd) { _, _ in + clampAnchorToSemester() + } + .onChange(of: selectedCourse?.id) { _, courseID in + scheduleCrownFocusRestore(afterClosing: courseID == nil) + } + .onDisappear { + crownFocused = false + restoreCrownFocusTask?.cancel() + crownIdleCoordinator.cancel() + pageTransitionTask?.cancel() + } + } + + /// 生成相邻三周的网格,仅当前周接收点击和教学坐标上报。 + private func weekPage(_ relativePage: Int) -> some View { + let pageStart = weekDate(relativePage) + return WeekSchedulePageContent( + weekStart: pageStart, + courses: courses(in: pageStart), + languageIdentifier: store.preferredLanguageIdentifier, + select: selectCourse, + onEmptyTap: handleEmptyTap, + // 分页器会同时构建前、中、后三页。只有当前页拿到教学目标, + // 从根源上杜绝相邻页上报同 ID 课程的屏外坐标。 + onboardingTargetCourse: + relativePage == 0 ? onboardingTargetCourse : nil, + reportCourseFrame: onCourseFrameChange + ) + .equatable() + .allowsHitTesting(relativePage == 0) + } + + /// 返回三页周分页器中某一位置对应的周一。 + private func weekDate(_ relativePage: Int) -> Date { + Calendar.current.date( + byAdding: .day, + value: relativePage * 7, + to: weekStart + ) ?? weekStart + } + + private func beginWeekHorizontalDrag() { + guard selectedCourse == nil, !pageTransitionInFlight else { return } + crownIdleCoordinator.cancel() + horizontalTouchStartOffset = horizontalPageOffset + crownFocused = true + onCrownInteraction() + } + + private func updateWeekHorizontalDrag(_ translation: CGFloat) { + guard selectedCourse == nil, !pageTransitionInFlight else { return } + let offset = horizontalTouchStartOffset + translation + + // 触摸期间保持当前三页的身份稳定,松手吸附后才提交周次。否则拖过 + // 一屏时中途换底会重建手势宿主,表现为页面短暂反向跳动或抽动。 + let attemptedDirection = offset < 0 ? 1 : -1 + let candidate = Calendar.current.date( + byAdding: .day, + value: attemptedDirection * 7, + to: anchorDate + ) ?? anchorDate + if offset != 0, !isWeekInsideSemester(candidate) { + if !weekBoundaryHapticPlayed { + WatchHaptics.boundary(attemptedDirection) + weekBoundaryHapticPlayed = true + } + let resisted = min(horizontalPageWidth * 0.2, abs(offset) * 0.18) + horizontalPageOffset = (offset < 0 ? -1 : 1) * resisted + return + } + + weekBoundaryHapticPlayed = false + horizontalPageOffset = offset + } + + private func finishWeekHorizontalDrag(_ value: DragGesture.Value) { + guard selectedCourse == nil, !pageTransitionInFlight else { return } + onSwipeInput(.horizontal) + let motion = horizontalDragMotion( + value, + currentOffset: horizontalPageOffset, + pageWidth: horizontalPageWidth + ) + settleWeekPage(direction: motion.direction, velocity: motion.velocity) + } + + /// 顶部按钮与触摸、表冠共用相同的横向动画。 + private func requestWeekPage(_ amount: Int) { + guard selectedCourse == nil, !pageTransitionInFlight else { return } + crownIdleCoordinator.cancel() + crownFocused = true + onCrownInteraction() + settleWeekPage(direction: amount, velocity: horizontalPageWidth * 2.2) + } + + /// 详情完全退出后再把表冠焦点交还周视图。 + /// + /// 立即聚焦底层周视图会与详情的移除转场争夺实体表上的 Crown/Scroll + /// 响应器。延迟略长于 0.38 秒弹簧主响应时间,可避免真机保留详情命中 + /// 层;模拟器与真机随后都恢复相同的周视图表冠行为。 + private func scheduleCrownFocusRestore(afterClosing: Bool) { + restoreCrownFocusTask?.cancel() + restoreCrownFocusTask = nil + guard afterClosing else { + crownFocused = false + return + } + + restoreCrownFocusTask = makeWatchAutoDismissTask(after: 0.42) { + restoreCrownFocusTask = nil + guard selectedCourse == nil else { return } + crownFocused = true + } + } + + /// 优先采用手机同步的周次参考;缺少参考时按学期开始日期推算。 + private var weekTitle: String { + if let reference = store.synchronizedWeekReference { + let referenceWeek = calendarWeekStart(containing: reference.date) + let elapsedDays = Calendar.current.dateComponents( + [.day], + from: referenceWeek, + to: weekStart + ).day ?? 0 + let zeroBasedIndex = reference.zeroBasedIndex + elapsedDays / 7 + return localizedWeekNumber(max(1, zeroBasedIndex + 1)) + } + + let termStart = calendarWeekStart( + containing: store.semesterStart ?? weekStart + ) + let elapsedDays = Calendar.current.dateComponents( + [.day], + from: termStart, + to: weekStart + ).day ?? 0 + return localizedWeekNumber(max(1, elapsedDays / 7 + 1)) + } + + /// 左右按钮按整周移动,并限制在手机端相同的整学期周次范围内。 + @discardableResult + private func moveWeek( + _ amount: Int, + playsBoundaryFeedback: Bool = true + ) -> Bool { + let nextDate = Calendar.current.date( + byAdding: .day, + value: amount * 7, + to: anchorDate + ) ?? anchorDate + guard nextDate != anchorDate else { return false } + + guard isWeekInsideSemester(nextDate) else { + if playsBoundaryFeedback { + WatchHaptics.boundary(amount) + } + return false + } + + WatchHaptics.navigation(amount) + anchorDate = nextDate + return true + } + + /// 将表冠的连续刻度直接映射成页面像素,速度越快每刻度推进越多。 + private func applyWeekCrownDelta( + _ delta: Double, + velocity: Double + ) { + guard !pageTransitionInFlight else { return } + let motion = calendarCrownPageMotion( + delta: delta, + velocity: velocity, + pageWidth: horizontalPageWidth, + distanceScale: crownPageRamp.distanceScale + ) + horizontalCrownVelocity = motion.velocity + if updateContinuousWeekOffset(by: motion.offsetDelta) != 0 { + crownPageRamp.recordCommittedPage() + } + } + + /// 完整跨过一屏后立即换底,使同一次表冠旋转可以无缝连续翻周。 + @discardableResult + private func updateContinuousWeekOffset(by delta: CGFloat) -> Int { + guard horizontalPageWidth > 0 else { return 0 } + let offset = horizontalPageOffset + delta + + // 学期首尾只显示带阻尼的边缘位移,不允许无效相邻周占满屏幕。 + let attemptedDirection = offset < 0 ? 1 : -1 + let candidate = Calendar.current.date( + byAdding: .day, + value: attemptedDirection * 7, + to: anchorDate + ) ?? anchorDate + if offset != 0, !isWeekInsideSemester(candidate) { + if !weekBoundaryHapticPlayed { + WatchHaptics.boundary(attemptedDirection) + weekBoundaryHapticPlayed = true + } + let resisted = min( + horizontalPageWidth * 0.2, + abs(offset) * 0.18 + ) + performWithoutAnimation { + horizontalPageOffset = (offset < 0 ? -1 : 1) * resisted + } + return 0 + } + weekBoundaryHapticPlayed = false + + let update = normalizedContinuousPageOffset( + offset, + pageWidth: horizontalPageWidth + ) + + guard update.crossedPage != 0 else { + performWithoutAnimation { + horizontalPageOffset = update.offset + } + return 0 + } + + performWithoutAnimation { + _ = moveWeek( + update.crossedPage, + playsBoundaryFeedback: false + ) + horizontalPageOffset = update.offset + } + return update.crossedPage + } + + /// 系统报告空闲后确认没有新刻度,才启动周页面吸附。 + private func handleWeekCrownIdle() { + crownIdleCoordinator.scheduleIdleConfirmation { + settleWeekCrownAfterInput() + } + } + + /// 将当前周偏移吸附到最近一页;供系统回调与实体表兜底共同调用。 + private func settleWeekCrownAfterInput() { + guard selectedCourse == nil, !pageTransitionInFlight else { return } + let direction = nearestPageDirection( + for: horizontalPageOffset, + width: horizontalPageWidth + ) + settleWeekPage( + direction: direction, + velocity: horizontalCrownVelocity + ) + } + + /// 动画结束后提交周次;越过学期边界时回弹并使用既有边界反馈。 + private func settleWeekPage(direction: Int, velocity: CGFloat) { + crownIdleCoordinator.cancel() + var direction = min(1, max(-1, direction)) + if direction != 0 { + let candidate = Calendar.current.date( + byAdding: .day, + value: direction * 7, + to: anchorDate + ) ?? anchorDate + if !isWeekInsideSemester(candidate) { + WatchHaptics.boundary(direction) + direction = 0 + } + } + + let snap = horizontalPageSnap( + direction: direction, + currentOffset: horizontalPageOffset, + velocity: velocity, + width: horizontalPageWidth + ) + pageTransitionToken += 1 + let token = pageTransitionToken + pageTransitionTask?.cancel() + pageTransitionInFlight = true + withAnimation(calendarPageSnapAnimation(duration: snap.duration)) { + horizontalPageOffset = snap.target + } + + pageTransitionTask = makeCalendarPageCompletionTask( + after: snap.duration + ) { + guard token == pageTransitionToken else { return } + if snap.direction != 0 { + _ = moveWeek( + snap.direction, + playsBoundaryFeedback: false + ) + } + performWithoutAnimation { + horizontalPageOffset = 0 + } + pageTransitionInFlight = false + horizontalCrownVelocity = 0 + weekBoundaryHapticPlayed = false + crownSession.reset() + } + } + + /// 判断目标周是否落在手机可浏览的 `semesterLength` 个周页面内。 + private func isWeekInsideSemester(_ date: Date) -> Bool { + guard let bounds = semesterWeekBounds else { + // 缓存缺少完整学期元数据时暂不限制范围;学期快照安装后 + // `onChange` 会立即校正当前周。 + return true + } + let target = calendarWeekStart(containing: date) + return target >= bounds.first && target <= bounds.last + } + + /// 把当前周钳制到手机端的第一周或最后一周。 + private func clampAnchorToSemester() { + guard let bounds = semesterWeekBounds else { return } + let current = calendarWeekStart(containing: anchorDate) + let clamped = min(max(current, bounds.first), bounds.last) + guard clamped != current else { return } + anchorDate = clamped + } + + /// 把同步快照的左闭右开日期范围换算成首、末周的周一。 + private var semesterWeekBounds: (first: Date, last: Date)? { + guard let rangeStart = store.semesterRangeStart, + let rangeEnd = store.semesterRangeEnd, + rangeEnd > rangeStart + else { + return nil + } + + let first = calendarWeekStart(containing: rangeStart) + // `rangeEnd` 是右开边界,减去一秒后才属于手机最后一个周页面。 + let lastIncludedDate = rangeEnd.addingTimeInterval(-1) + let last = calendarWeekStart(containing: lastIncludedDate) + return (first, max(first, last)) + } + + /// 打开课程详情前取消表冠焦点并隐藏根页面悬浮按钮。 + private func selectCourse(_ course: WatchCourse) { + crownFocused = false + onCrownInteraction() + onCourseSelected(course) + WatchHaptics.selection() + withAnimation(.spring(response: 0.38, dampingFraction: 0.84)) { + selectedCourse = course + } + } + + /// 空白区域轻点只恢复控件,不改变课程选择。 + private func handleEmptyTap() { + crownFocused = true + onEmptyTap() + } + + /// 透明焦点节点只观察表冠旋转,不参与可见布局和点击命中。 + private var crownObserver: some View { + Color.clear + .frame(width: 1, height: 1) + .calendarPagingCrownInput( + detent: $crownValue, + focused: $crownFocused, + onChange: { event in + handleWeekCrownChange(event) + }, + onIdle: { + handleWeekCrownIdle() + } + ) + .accessibilityHidden(true) + } + + /// 周视图一开始转动表冠就进入横向拖页,停止后自动吸附最近页。 + private func handleWeekCrownChange( + _ event: DigitalCrownEvent + ) { + guard selectedCourse == nil, !pageTransitionInFlight, + event.offset.isFinite, event.velocity.isFinite else { return } + let delta = frameBoundCrownDelta( + from: lastCrownEventOffset, + to: event.offset + ) + lastCrownEventOffset = event.offset + guard let update = crownSession.register(delta: delta) else { return } + // 只有有效新刻度才撤销停止确认;零事件不能吞掉实体表的兜底吸附。 + crownIdleCoordinator.cancel() + onCrownInput() + crownPageRamp.register(update) + + onCrownInteraction() + applyWeekCrownDelta(delta, velocity: event.velocity) + crownIdleCoordinator.scheduleFallback { + settleWeekCrownAfterInput() + } + } +} + +/// 周分页器中一张按真实周一复用的页面。 +/// +/// 横向拖动或表冠滚动只改变父容器偏移;周起点、课程与语言未变时,这张 +/// 页面跳过网格路径、节次推断和色块坐标的重复计算。跨周后已经预渲染的 +/// 相邻页会直接成为当前页,只在屏幕外创建新的边缘周。 +private struct WeekSchedulePageContent: View, Equatable { + let weekStart: Date + let courses: [WatchCourse] + let languageIdentifier: String + let select: (WatchCourse) -> Void + let onEmptyTap: () -> Void + let onboardingTargetCourse: WatchCourse? + let reportCourseFrame: (WatchCourse, CGRect) -> Void + + static func == ( + lhs: WeekSchedulePageContent, + rhs: WeekSchedulePageContent + ) -> Bool { + lhs.weekStart == rhs.weekStart + && lhs.courses == rhs.courses + && lhs.languageIdentifier == rhs.languageIdentifier + && lhs.onboardingTargetCourse == rhs.onboardingTargetCourse + } + + var body: some View { + GeometryReader { proxy in + let topBarContentInset = max(26, proxy.size.height * 0.13) + let weekdayHeight = max(15, proxy.size.height * 0.075) + + VStack(spacing: max(1, proxy.size.height * 0.008)) { + WeekdayHeader(weekStart: weekStart) + .frame(height: weekdayHeight) + .offset(y: 2) + + WeekPeriodGrid( + weekStart: weekStart, + courses: courses, + select: select, + onEmptyTap: onEmptyTap, + onboardingTargetCourse: onboardingTargetCourse, + reportCourseFrame: reportCourseFrame + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .layoutPriority(1) + } + .padding(.top, topBarContentInset) + } + } +} + +/// 周网格顶部的月份、星期和日期行。 +private struct WeekdayHeader: View { + let weekStart: Date + + var body: some View { + GeometryReader { proxy in + let fontSize = max(6, min(8, proxy.size.width * 0.035)) + let labelWidth = max(11, min(15, proxy.size.width * 0.075)) + let symbols = mondayFirstWeekdaySymbols() + HStack(spacing: 0) { + Text(weekStart, format: .dateTime.month(.abbreviated)) + .font(.system(size: fontSize, weight: .medium)) + .foregroundStyle(.secondary) + .minimumScaleFactor(0.75) + .lineLimit(1) + .frame(width: labelWidth) + + ForEach(0..<7, id: \.self) { index in + let date = Calendar.current.date( + byAdding: .day, + value: index, + to: weekStart + ) ?? weekStart + let isToday = Calendar.current.isDateInToday(date) + VStack(spacing: -1) { + Text(symbols[index]) + Text(date, format: .dateTime.day()) + } + .font(.system(size: fontSize, weight: .medium)) + .frame(maxWidth: .infinity) + // 今天的表头使用不参与布局的淡色背景,避免改变列宽。 + // 它会与网格中的同列高亮带连成一条完整的“今天”标记。 + .background { + if isToday { + RoundedRectangle( + cornerRadius: 2, + style: .continuous + ) + .fill(Color.accentColor.opacity(0.18)) + } + } + .foregroundStyle( + isToday + ? Color.accentColor + : Color.secondary + ) + } + } + } + } +} + +/// 周课表的节次网格、课程色块和点击命中区域。 +private struct WeekPeriodGrid: View { + let weekStart: Date + let courses: [WatchCourse] + let select: (WatchCourse) -> Void + let onEmptyTap: () -> Void + let onboardingTargetCourse: WatchCourse? + let reportCourseFrame: (WatchCourse, CGRect) -> Void + + /// 第 11 节及之后开始的课程不进入当前 1–10 节网格。 + private var visibleCourses: [WatchCourse] { + courses.filter { + $0.startPeriod <= WeekScheduleGridGeometry.maximumPeriod + } + } + + var body: some View { + GeometryReader { proxy in + let geometry = WeekScheduleGridGeometry(size: proxy.size) + let labelFontSize = max(5.5, min(7, proxy.size.width * 0.032)) + let globalGridFrame = proxy.frame(in: .global) + + ZStack(alignment: .topLeading) { + Color.clear + + todayColumnHighlight( + geometry: geometry + ) + + gridLines(geometry: geometry) + + ForEach( + 1...WeekScheduleGridGeometry.maximumPeriod, + id: \.self + ) { period in + Text("\(period)") + .font(.system(size: labelFontSize, design: .rounded)) + .foregroundStyle(.secondary) + .frame(width: geometry.labelWidth, height: 8) + .offset( + x: 0, + y: geometry.periodStartUnit(period) + * geometry.unitHeight + + 2.5 * geometry.unitHeight - 4 + ) + } + + ForEach(visibleCourses) { course in + let frame = geometry.courseFrame(for: course) + + RoundedRectangle( + cornerRadius: 2.5, + style: .continuous + ) + .fill(course.color) + .frame( + width: frame.width, + height: frame.height + ) + .offset( + x: frame.minX, + y: frame.minY + ) + .contentShape(Rectangle()) + .accessibilityLabel( + localizedCoursePeriodRange(course) + ) + .accessibilityAddTraits(.isButton) + .accessibilityAction { + select(course) + } + } + } + .contentShape(Rectangle()) + // 课程/空白点击优先于父层分页拖动。真正产生横向位移时 + // SpatialTapGesture 会自然失败,再由分页手势接管。 + .highPriorityGesture( + SpatialTapGesture() + .onEnded { value in + if let course = course( + at: value.location, + geometry: geometry + ) { + select(course) + } else { + onEmptyTap() + } + } + ) + // 教学圆心使用绘制/命中共用的课程矩形公式,再加上网格全局原点。 + // 因而圆心不会受分页预渲染、过渡动画或回报先后顺序影响。 + .onAppear { + reportOnboardingTarget( + geometry: geometry, + globalGridFrame: globalGridFrame + ) + } + .onChange(of: globalGridFrame) { _, newFrame in + reportOnboardingTarget( + geometry: geometry, + globalGridFrame: newFrame + ) + } + .onChange(of: onboardingTargetCourse) { _, _ in + reportOnboardingTarget( + geometry: geometry, + globalGridFrame: globalGridFrame + ) + } + } + } + + /// 由时间网格的确定性边界反算教学目标的全局矩形。 + private func reportOnboardingTarget( + geometry: WeekScheduleGridGeometry, + globalGridFrame: CGRect + ) { + guard let course = onboardingTargetCourse, + globalGridFrame.width > 0, + globalGridFrame.height > 0 + else { return } + let localFrame = geometry.courseFrame(for: course) + reportCourseFrame( + course, + localFrame.offsetBy( + dx: globalGridFrame.minX, + dy: globalGridFrame.minY + ) + ) + } + + /// 当前展示周包含今天时,在今天所在列的底层绘制一条淡色高亮带。 + /// + /// 高亮位于网格线和课程色块下方,不覆盖课程颜色,也不参与手势命中。 + @ViewBuilder + private func todayColumnHighlight( + geometry: WeekScheduleGridGeometry + ) -> some View { + if let column = todayColumnIndex { + Rectangle() + .fill(Color.accentColor.opacity(0.09)) + .frame( + width: geometry.columnWidth, + height: geometry.size.height + ) + .offset( + x: geometry.labelWidth + + CGFloat(column) * geometry.columnWidth, + y: 0 + ) + .accessibilityHidden(true) + } + } + + /// 仅当今天位于当前展示的七天范围内时,返回“周一为 0”的列序号。 + private var todayColumnIndex: Int? { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + let displayedStart = calendar.startOfDay(for: weekStart) + let displayedEnd = calendar.date( + byAdding: .day, + value: 7, + to: displayedStart + ) ?? displayedStart + + guard today >= displayedStart, today < displayedEnd else { + return nil + } + return WeekScheduleGridGeometry.weekdayIndex(for: today) + } + + /// 在与绘制完全相同的几何参数下执行命中测试。 + /// + /// `last` 与 ZStack 最后绘制者优先的规则一致;即使未来出现重叠课程, + /// 用户点到的也会是视觉上位于最上层的色块。 + private func course( + at location: CGPoint, + geometry: WeekScheduleGridGeometry + ) -> WatchCourse? { + visibleCourses.last { course in + geometry.courseFrame(for: course).contains(location) + } + } + + /// 绘制七列和十节课的辅助线。 + private func gridLines(geometry: WeekScheduleGridGeometry) -> some View { + Path { path in + for column in 0...7 { + let x = geometry.labelWidth + + CGFloat(column) * geometry.columnWidth + path.move(to: CGPoint(x: x, y: 0)) + path.addLine(to: CGPoint(x: x, y: geometry.size.height)) + } + + for period in 1...WeekScheduleGridGeometry.maximumPeriod { + let y = geometry.periodStartUnit(period) + * geometry.unitHeight + path.move(to: CGPoint(x: geometry.labelWidth, y: y)) + path.addLine(to: CGPoint(x: geometry.size.width, y: y)) + } + path.move( + to: CGPoint( + x: geometry.labelWidth, + y: geometry.size.height + ) + ) + path.addLine( + to: CGPoint( + x: geometry.size.width, + y: geometry.size.height + ) + ) + } + .stroke(.secondary.opacity(0.18), lineWidth: 0.5) + } +} + +/// 周网格唯一的几何模型。 +/// +/// SwiftUI 的 Path、课程色块、触摸命中和新手引导全部复用这份计算。课程 +/// 位置只由网格尺寸、星期和开始/结束节次决定,不再依赖渲染后的视图采样。 +private struct WeekScheduleGridGeometry { + static let maximumPeriod = 10 + private static let totalUnits: CGFloat = 56 + + let size: CGSize + + var labelWidth: CGFloat { + max(11, min(15, size.width * 0.075)) + } + + var columnWidth: CGFloat { + max(1, (size.width - labelWidth) / 7) + } + + var unitHeight: CGFloat { + max(0.5, size.height / Self.totalUnits) + } + + /// 返回与色块绘制完全一致的本地矩形。 + func courseFrame(for course: WatchCourse) -> CGRect { + let start = periodStartUnit(course.startPeriod) + let end = periodEndUnit(course.endPeriod) + let weekday = Self.weekdayIndex(for: course.startAt) + return CGRect( + x: labelWidth + CGFloat(weekday) * columnWidth + 0.75, + y: start * unitHeight + 0.5, + width: max(3, columnWidth - 1.5), + height: max(3, (end - start) * unitHeight - 1) + ) + } + + /// 把节次映射到纵向单位;第 4、8 节后保留午休/晚休间隔。 + func periodStartUnit(_ period: Int) -> CGFloat { + let period = min(Self.maximumPeriod, max(1, period)) + if period <= 4 { + return CGFloat(period - 1) * 5 + } + if period <= 8 { + return CGFloat(period - 1) * 5 + 3 + } + return CGFloat(period - 1) * 5 + 6 + } + + /// 每节课固定占五个纵向单位。 + private func periodEndUnit(_ period: Int) -> CGFloat { + periodStartUnit(period) + 5 + } + + /// 将 Foundation 的周日为 1 转换为周一为 0。 + static func weekdayIndex(for date: Date) -> Int { + let weekday = Calendar.current.component(.weekday, from: date) + return max(0, min(6, (weekday + 5) % 7)) + } +} + +/// 按手机端相同的系统区域规则,生成“第 N 周”标题。 +private func localizedWeekNumber(_ number: Int) -> String { + watchLocalizedFormat("第%lld周", + Int64(number) + ) +} + +/// 生成 VoiceOver 使用的本地化节次范围。 +private func localizedCoursePeriodRange(_ course: WatchCourse) -> String { + watchLocalizedFormat("%1$@,第%2$lld到第%3$lld节", + course.name, + Int64(course.startPeriod), + Int64(course.endPeriod) + ) +} diff --git a/watchOS/Widget/Info.plist b/watchOS/Widget/Info.plist new file mode 100644 index 00000000..9e16afde --- /dev/null +++ b/watchOS/Widget/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + 当前课程 + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/watchOS/Widget/TraintimeScheduleWidget.swift b/watchOS/Widget/TraintimeScheduleWidget.swift new file mode 100644 index 00000000..16a7c650 --- /dev/null +++ b/watchOS/Widget/TraintimeScheduleWidget.swift @@ -0,0 +1,626 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import AppIntents +import SwiftUI +import WidgetKit + +private enum CircularScheduleTypography { + static let primary = WatchWidgetDesignTokens.circularPrimary + static let secondary = WatchWidgetDesignTokens.circularSecondary + static let minimumScale: CGFloat = 0.85 +} + +struct TraintimeScheduleWidgetEntry: TimelineEntry { + let date: Date + let schedule: WatchSchedulePresentation + let integrated: WatchSchedulePresentation +} + +struct TraintimeScheduleWidgetProvider: TimelineProvider { + func placeholder(in context: Context) -> TraintimeScheduleWidgetEntry { + makeEntry(at: Date(), resolved: sampleSchedule()) + } + + func getSnapshot( + in context: Context, completion: @escaping (TraintimeScheduleWidgetEntry) -> Void + ) { + completion( + makeEntry( + at: Date(), + resolved: WatchWidgetShared.loadResolvedSchedule() + ?? (context.isPreview ? sampleSchedule() : nil))) + } + + func getTimeline( + in context: Context, completion: @escaping (Timeline) -> Void + ) { + let now = Date() + let resolved = WatchWidgetShared.loadResolvedSchedule() + let expiry = + WatchWidgetShared.defaults?.object(forKey: WatchWidgetShared.previewExpiresKey) as? Date + let dates = WatchSchedulePresentation.timelineDates( + resolved: resolved, now: now, previewExpiry: expiry) + let entries = dates.map { makeEntry(at: $0, resolved: resolved) } + completion( + Timeline(entries: entries, policy: .after(dates.last ?? now.addingTimeInterval(3600)))) + } + + private func makeEntry(at date: Date, resolved: WatchResolvedSchedule?) + -> TraintimeScheduleWidgetEntry + { + let defaults = WatchWidgetShared.defaults + let signedOut = defaults?.bool(forKey: WatchWidgetShared.signedOutKey) ?? false + let normal = WatchSchedulePresentation(resolved: resolved, at: date, signedOut: signedOut) + let expiry = + defaults?.object(forKey: WatchWidgetShared.previewExpiresKey) as? Date ?? .distantPast + let preview = + normal.current != nil + && defaults?.string(forKey: WatchWidgetShared.selectedCurrentCourseKey) + == normal.current?.id + && date < expiry + return .init( + date: date, schedule: normal, + // 普通状态复用同一结果,避免每个时间线节点重复扫描课表。 + integrated: preview + ? WatchSchedulePresentation( + resolved: resolved, at: date, signedOut: signedOut, preview: true) + : normal) + } + + private func sampleSchedule() -> WatchResolvedSchedule? { + let now = Date() + let calendar = WatchSchedulePresentation.calendar(offsetMinutes: 480) + let start = now.addingTimeInterval(-600) + let end = now.addingTimeInterval(2400) + let course = WatchCourse( + id: "preview", name: watchLocalizedString("高等数学"), teacher: nil, classroom: "B-302", + startAtEpochMs: Int64(start.timeIntervalSince1970 * 1000), + endAtEpochMs: Int64(end.timeIntervalSince1970 * 1000), + startSection: 1, endSection: 2, colorARGB: 0xFF21_96F3, kind: "course", note: nil) + let snapshot = WatchScheduleSnapshot( + schemaVersion: 4, generatedAtEpochMs: Int64(now.timeIntervalSince1970 * 1000), + semesterStartEpochMs: nil, currentWeekIndex: 1, + validThroughEpochMs: Int64(now.addingTimeInterval(604800).timeIntervalSince1970 * 1000), + rangeStartEpochMs: Int64(calendar.startOfDay(for: now).timeIntervalSince1970 * 1000), + rangeEndEpochMs: Int64(now.addingTimeInterval(604800).timeIntervalSince1970 * 1000), + timeZoneOffsetMinutes: 480, reminderMinutes: 15, courses: [course]) + return WatchScheduleResolver.resolve([.fourteenDays: snapshot]) + } +} + +struct ToggleScheduleWidgetCourseIntent: AppIntent { + static let title: LocalizedStringResource = "切换当前与下一节课" + static let openAppWhenRun = false + @Parameter(title: "当前课程 ID") var currentCourseID: String + init() { currentCourseID = "" } + init(currentCourseID: String) { self.currentCourseID = currentCourseID } + + func perform() async throws -> some IntentResult { + guard let defaults = WatchWidgetShared.defaults else { return .result() } + let now = Date() + let schedule = WatchSchedulePresentation( + resolved: WatchWidgetShared.loadResolvedSchedule(), at: now) + guard let current = schedule.current, current.id == currentCourseID, let next = schedule.next + else { return .result() } + let expiry = + defaults.object(forKey: WatchWidgetShared.previewExpiresKey) as? Date ?? .distantPast + if defaults.string(forKey: WatchWidgetShared.selectedCurrentCourseKey) == current.id + && now < expiry + { + defaults.removeObject(forKey: WatchWidgetShared.selectedCurrentCourseKey) + defaults.removeObject(forKey: WatchWidgetShared.previewExpiresKey) + } else { + defaults.set(current.id, forKey: WatchWidgetShared.selectedCurrentCourseKey) + // 下一节开始、当前课程结束或五分钟到期时恢复正常状态,取先到者。 + defaults.set( + min(current.endAt, next.startAt, now.addingTimeInterval(300)), + forKey: WatchWidgetShared.previewExpiresKey) + } + WidgetCenter.shared.reloadTimelines(ofKind: WatchWidgetShared.widgetKind) + return .result() + } +} + +private enum ScheduleWidgetRole { + case integrated, name, timeLocation, overview +} + +/// 长方形组件用一行时间范围呈现起止时刻,省去重复的上下课标签。 +private struct ScheduleTime: View { + let schedule: WatchSchedulePresentation + + var body: some View { + if let start = schedule.startTimeText, let end = schedule.endTimeText { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(start) + Spacer(minLength: 0) + Text("–") + .foregroundStyle(.secondary) + Spacer(minLength: 0) + Text(end) + } + .font(WatchWidgetDesignTokens.rectangularInfo) + .foregroundStyle(.primary) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.8) + .accessibilityElement(children: .ignore) + .accessibilityLabel( + watchLocalizedString("上课") + " " + start + "," + + watchLocalizedString("下课") + " " + end) + } + } +} + +private struct ScheduleProgress: View { + let progress: Double + let color: Color + + var body: some View { + // 普通形状只接收模型校验后的有限进度值,不使用系统计时进度视图。 + Capsule() + .fill(.secondary.opacity(0.25)) + .overlay(alignment: .leading) { + Capsule() + .fill(color) + .scaleEffect(x: progress, y: 1, anchor: .leading) + .widgetAccentable() + } + .frame(height: 3) + .accessibilityLabel(watchLocalizedString("课程进度")) + .accessibilityValue(Text(progress, format: .percent.precision(.fractionLength(0)))) + } +} + +private struct ScheduleWidgetView: View { + let entry: TraintimeScheduleWidgetEntry + let role: ScheduleWidgetRole + @Environment(\.widgetFamily) private var family + private var schedule: WatchSchedulePresentation { + role == .integrated ? entry.integrated : entry.schedule + } + private var color: Color { schedule.focus?.color ?? .secondary } + private var switchableCurrentCourse: WatchCourse? { + guard role == .integrated, entry.schedule.next != nil else { return nil } + return entry.schedule.current + } + + var body: some View { + Group { + switch family { + case .accessoryInline: inline + case .accessoryCorner: corner + case .accessoryCircular: circular + default: rectangular + } + } + .containerBackground(for: .widget) { Color.clear } + .environment(\.locale, WatchWidgetShared.preferredLocale) + .widgetURL(WatchWidgetDestination.overview.url) + } + + private var inline: some View { + Group { + if role == .overview { + Label(summaryText, systemImage: "calendar") + } else if let course = schedule.focus { + if role == .name { + Text(nameContext + " · " + course.name) + } else { + inlineTime + Text(" · " + schedule.compactLocation) + } + } else { + Label(schedule.compactTitle, systemImage: schedule.emptySymbol) + } + } + } + + private var inlineTime: Text { + guard let time = schedule.compactTime, let course = schedule.focus else { return Text("") } + let target = schedule.isCurrent ? course.endAt : course.startAt + let dayLabel = schedule.compactDayLabel(for: target) + let day = dayLabel.isEmpty ? "" : dayLabel + " " + return Text(day + time.label + " " + time.value) + } + + private var circular: some View { + circularContent + // 所有类型和空状态都进入同一个表盘着色组。全彩模式统一使用 + // 系统前景色,避免圆环单独使用课程色、文字却使用另一种颜色。 + .foregroundStyle(.primary) + .tint(Color.primary) + .symbolRenderingMode(.monochrome) + .widgetAccentable() + } + + @ViewBuilder private var circularContent: some View { + if role == .overview { + summaryCircle + } else if let course = schedule.focus { + if role == .name { + VStack(spacing: 2) { + Text(nameContext).font(CircularScheduleTypography.secondary) + .foregroundStyle(.secondary).lineLimit(1) + .minimumScaleFactor(CircularScheduleTypography.minimumScale) + circularTitle(course.name) + } + .multilineTextAlignment(.center) + } else { + if let progress = schedule.courseProgress { + // 系统开口圆环负责轨道和进度圆点;底部开口放地点。 + Gauge(value: progress, in: 0...1) { + compactLocation + } currentValueLabel: { + compactTimeValue + } + .gaugeStyle(.accessoryCircular) + .accessibilityElement(children: .ignore) + .accessibilityLabel(compactAccessibilityLabel) + .accessibilityValue( + Text(progress, format: .percent.precision(.fractionLength(0)))) + } else { + compactTimeLocation + } + } + } else { + emptyCompact + } + } + + private var compactTimeLocation: some View { + VStack(spacing: WatchWidgetDesignTokens.circularSpacing) { + Text(compactTimeHeading) + .font(CircularScheduleTypography.secondary) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(CircularScheduleTypography.minimumScale) + circularTitle(schedule.compactTime?.value ?? "", lineLimit: 1) + .monospacedDigit() + compactLocation + } + .padding(.horizontal, 4) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .multilineTextAlignment(.center) + .accessibilityElement(children: .ignore) + .accessibilityLabel(compactAccessibilityLabel) + } + + private var compactTimeValue: some View { + VStack(spacing: 0) { + Text(compactTimeHeading) + .font(WatchWidgetDesignTokens.circularProgressHeading) + .foregroundStyle(.secondary) + Text(schedule.compactTime?.value ?? "") + .font(CircularScheduleTypography.primary) + .monospacedDigit() + } + .lineLimit(1) + .minimumScaleFactor(0.85) + .multilineTextAlignment(.center) + } + + private var compactLocation: some View { + // 有无圆环都把位置作为主要信息,与时刻共用字号、字重和前景色。 + circularTitle(schedule.compactLocation, lineLimit: 1) + .foregroundStyle(.primary) + } + + private var compactAccessibilityLabel: String { + [schedule.focus?.name, compactTimeHeading, schedule.compactTime?.value, schedule.location] + .compactMap { $0 }.joined(separator: ",") + } + + private func cornerText( + _ value: String, + font: Font = WatchWidgetDesignTokens.cornerPrimary + ) -> some View { + Text(value) + .font(font) + .lineLimit(1) + .truncationMode(.tail) + .foregroundStyle(color) + .widgetAccentable() + } + + /// 跨日提示合并进第一行,内容始终保持“上/下课、时刻、地点”三行。 + private var compactTimeHeading: String { + guard let time = schedule.compactTime, let course = schedule.focus else { return "" } + let target = schedule.isCurrent ? course.endAt : course.startAt + let day = schedule.compactDayLabel(for: target) + return day.isEmpty ? time.label : day + " " + time.label + } + + @ViewBuilder private var corner: some View { + if let course = schedule.focus { + if let progress = schedule.courseProgress { + cornerText(course.name) + .widgetCurvesContent() + .widgetLabel { + // 数值 Gauge 由 WidgetKit 排成表角弧线,不展示百分比或时间。 + Gauge(value: progress, in: 0...1) { + EmptyView() + } + .gaugeStyle(.accessoryLinearCapacity) + .tint(color) + .accessibilityLabel(watchLocalizedString("课程进度")) + } + } else { + // 未上课时位置置于开头,空间不足时从尾部省略课程名。 + let courseLabel = [schedule.compactLocation, course.name] + .filter { !$0.isEmpty }.joined(separator: " · ") + cornerText( + schedule.compactDateTimeText(for: course.startAt), + font: WatchWidgetDesignTokens.cornerTime + ) + .widgetCurvesContent() + .widgetLabel { + cornerText(courseLabel) + } + } + } else { + Image(systemName: schedule.emptySymbol).widgetLabel { Text(schedule.title) } + } + } + + @ViewBuilder private var rectangular: some View { + if role == .overview + && ![.semesterEnded, .signedOut, .noData, .expired].contains(schedule.state) + { + overviewRectangle + } else if let course = schedule.focus { + let courseProgress: Double? = role == .name ? nil : schedule.courseProgress + // 进度条占一行,行间距与外侧留白共同控制组件总高度。 + VStack(alignment: .leading, spacing: courseProgress == nil ? 2 : 4) { + HStack(spacing: 3) { + Text( + role == .name + ? nameContext + : (role == .integrated + ? contextTitle + " · " + course.name : contextTitle) + ) + .font(.system(size: role == .name ? 10 : 12, weight: .medium)) + .lineLimit(1).minimumScaleFactor(0.8) + Spacer(minLength: 0) + if switchableCurrentCourse != nil { + // 标题为按钮留出横向空间;更高的触摸区域在卡片上叠放, + // 不撑高标题行或挤压下面的时间、进度与地点。 + Color.clear.frame(width: 40, height: 16) + .accessibilityHidden(true) + } + } + .foregroundStyle(color) + if role == .name { + Label(course.name, systemImage: course.kindSystemImage) + .font(.system(size: 16, weight: .semibold)).lineLimit(2).minimumScaleFactor( + 0.8) + if let kind = course.kindTitle { + Text(kind).font(.caption2).foregroundStyle(.secondary) + } + } else { + ScheduleTime(schedule: schedule) + if let progress = courseProgress { + ScheduleProgress(progress: progress, color: color) + } + Label(schedule.locationSummary, systemImage: "mappin") + .font(WatchWidgetDesignTokens.rectangularInfo).lineLimit(1) + .minimumScaleFactor(0.8) + .truncationMode(.tail) + .foregroundStyle(.primary) + } + } + .overlay(alignment: .topTrailing) { + if let current = switchableCurrentCourse { + Button( + intent: ToggleScheduleWidgetCourseIntent(currentCourseID: current.id) + ) { + Image(systemName: schedule.isPreview ? "arrow.uturn.backward" : "arrow.right") + .font(.system(size: 11, weight: .semibold)) + .frame(width: 20, height: 16) + .frame(width: 40, height: 32, alignment: .topTrailing) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(color) + .accessibilityLabel(watchLocalizedString("切换当前与下一节课")) + } + } + .padding(.horizontal, 2) + .padding(.vertical, courseProgress == nil ? 2 : 0) + } else { + HStack(spacing: 8) { + Image(systemName: schedule.emptySymbol).font(.title3).foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 3) { + Text(schedule.title).font(.system(size: 13, weight: .semibold)).lineLimit(2) + if [.noData, .unconfirmed, .expired, .signedOut].contains(schedule.state) { + Text(watchLocalizedString("打开手机更新课表")).font(.system(size: 10)) + .foregroundStyle(.secondary) + } else if [.todayFinished, .todayFree].contains(schedule.state) { + Text(watchLocalizedString("后续课表待同步")).font(.system(size: 10)) + .foregroundStyle(.secondary) + } + } + } + } + } + + private var contextTitle: String { + guard let course = schedule.focus else { return schedule.title } + if !schedule.calendar.isDate(course.startAt, inSameDayAs: schedule.date) { + return schedule.title + " · " + schedule.dayLabel(for: course.startAt) + } + return schedule.title + } + private var nameContext: String { + guard let course = schedule.focus else { return schedule.title } + return schedule.calendar.isDate(course.startAt, inSameDayAs: schedule.date) + ? schedule.title : schedule.dayLabel(for: course.startAt) + } + private var emptyCompact: some View { + circularStatus(symbol: schedule.emptySymbol, title: schedule.compactTitle) + .accessibilityLabel(schedule.title) + } + + /// 课程名称、时刻、位置和日程状态共用主要文字样式。 + private func circularTitle(_ title: String, lineLimit: Int = 2) -> some View { + Text(title) + .font(CircularScheduleTypography.primary) + .lineLimit(lineLimit) + .minimumScaleFactor(CircularScheduleTypography.minimumScale) + .truncationMode(.tail) + } + + private func circularStatus(symbol: String, title: String) -> some View { + VStack(spacing: 2) { + Image(systemName: symbol).font(.system(size: 14)) + circularTitle(title) + } + .multilineTextAlignment(.center) + .accessibilityElement(children: .ignore) + .accessibilityLabel(title) + } + private var summaryRemaining: Int { + schedule.summaryCourses.filter { $0.endAt > schedule.date }.count + } + private var summaryIsAvailable: Bool { + ![.semesterEnded, .signedOut, .noData, .expired].contains(schedule.state) + && schedule.summaryIsComplete + } + private var summaryText: String { + guard summaryIsAvailable else { + switch schedule.state { + case .semesterEnded, .signedOut, .noData, .expired: return schedule.compactTitle + default: return watchLocalizedString("日程概览待同步") + } + } + if schedule.summaryCourses.isEmpty { return watchLocalizedString("今日无课") } + if summaryRemaining == 0 { return watchLocalizedString("今日已下课") } + return watchLocalizedFormat("%@ %d 项 · 还剩 %d 项", + schedule.dayLabel(for: schedule.summaryDate), schedule.summaryCourses.count, + summaryRemaining) + } + @ViewBuilder private var summaryCircle: some View { + if summaryIsAvailable && summaryRemaining > 0 { + Gauge( + value: Double(schedule.summaryCourses.count - summaryRemaining), + in: 0...Double(schedule.summaryCourses.count) + ) { + Text(schedule.dayLabel(for: schedule.summaryDate)) + } currentValueLabel: { + VStack(spacing: 0) { + Text(watchLocalizedString("还剩")).font(.system(size: 7)) + Text("\(summaryRemaining)").font( + .system(size: 19, weight: .semibold, design: .rounded)) + } + } + .gaugeStyle(.accessoryCircular) + .accessibilityLabel(summaryText) + } else if summaryIsAvailable { + circularStatus( + symbol: schedule.summaryCourses.isEmpty ? "cup.and.saucer.fill" : "checkmark", + title: summaryText) + } else { + circularStatus(symbol: schedule.emptySymbol, title: summaryText) + } + } + private var overviewRectangle: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(watchLocalizedString("今日")) + Spacer(minLength: 0) + Text(schedule.weekIsComplete ? weekLabel : watchLocalizedString("本周日程待同步")) + .minimumScaleFactor(0.85) + } + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.secondary) + + Text(summaryIsAvailable && summaryRemaining > 0 + ? watchLocalizedFormat("还剩 %d 项", summaryRemaining) + : summaryText) + .font(.system(size: 20, weight: .semibold, design: .rounded)) + .minimumScaleFactor(0.85) + .widgetAccentable() + + if summaryIsAvailable { + if summaryRemaining > 0, + let lastEnd = schedule.summaryCourses.map(\.endAt).max() + { + Text(watchLocalizedFormat("%@ 全部结束", + schedule.compactDateTimeText(for: lastEnd))) + .font(.system(size: 11, weight: .medium)) + } else if let next = schedule.focus, next.startAt > schedule.date { + Text(watchLocalizedFormat("下一次 %@", + schedule.compactDateTimeText(for: next.startAt))) + .font(.system(size: 11, weight: .medium)) + } else if [.unconfirmed, .todayFree, .todayFinished].contains(schedule.state) { + Text(watchLocalizedString("后续课表待同步")) + .font(.system(size: 11)) + } + } else { + Text(watchLocalizedString("打开手机更新课表")) + .font(.system(size: 11)).foregroundStyle(.secondary) + } + } + .lineLimit(1) + .padding(.horizontal, 2) + .padding(.vertical, 3) + .frame(maxWidth: .infinity, alignment: .leading) + } + private var weekLabel: String { + let currentWeek = schedule.calendar.dateInterval(of: .weekOfYear, for: schedule.date)! + let label = + currentWeek.start == schedule.weekInterval.start + ? watchLocalizedString("本周") : schedule.dayLabel(for: schedule.weekInterval.start) + return watchLocalizedFormat("%@ %d 项", label, schedule.weekCourses.count) + } +} + +private struct ScheduleConfiguration { + let kind: String + let role: ScheduleWidgetRole + let name: LocalizedStringKey + let summary: LocalizedStringKey + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: TraintimeScheduleWidgetProvider()) { entry in + ScheduleWidgetView(entry: entry, role: role) + } + .configurationDisplayName(name) + .description(summary) + .supportedFamilies(role == .integrated + ? [.accessoryInline, .accessoryCircular, .accessoryCorner, .accessoryRectangular] + : [.accessoryInline, .accessoryCircular, .accessoryRectangular]) + } +} + +struct TraintimeScheduleWidget: Widget { + var body: some WidgetConfiguration { + ScheduleConfiguration( + kind: WatchWidgetShared.widgetKind, role: .integrated, name: "综合课表", + summary: "显示当前或下一节课,上课期间显示课程进度。" + ).body + } +} +struct TraintimeCourseNameWidget: Widget { + var body: some WidgetConfiguration { + ScheduleConfiguration( + kind: WatchWidgetShared.courseNameWidgetKind, role: .name, name: "课程名称", + summary: "显示课程名称和状态,搭配时间地点组件使用。" + ).body + } +} +struct TraintimeCourseTimeLocationWidget: Widget { + var body: some WidgetConfiguration { + ScheduleConfiguration( + kind: WatchWidgetShared.courseTimeLocationWidgetKind, role: .timeLocation, name: "时间地点", + summary: "显示上课、下课时间与地点,与课程名称组件保持一致。" + ).body + } +} +struct TraintimeTodayScheduleWidget: Widget { + var body: some WidgetConfiguration { + ScheduleConfiguration( + kind: WatchWidgetShared.todayScheduleWidgetKind, role: .overview, name: "日程概览", + summary: "显示今日剩余安排、结束时间和本周总数,轻点打开概览。" + ).body + } +} diff --git a/watchOS/Widget/TraintimeWatchWidgetBundle.swift b/watchOS/Widget/TraintimeWatchWidgetBundle.swift new file mode 100644 index 00000000..f6b7e019 --- /dev/null +++ b/watchOS/Widget/TraintimeWatchWidgetBundle.swift @@ -0,0 +1,19 @@ +// Copyright 2026 Traintime PDA Authors. +// SPDX-License-Identifier: MPL-2.0 + +import SwiftUI +import WidgetKit + +/// Apple Watch Widget Extension 入口。 +/// +/// 综合组件之外提供名称、时间地点、日程概览三个互补组件。 +/// 它们共用同一个 Provider 与 App Group 缓存,不会重复请求或复制课表数据。 +@main +struct TraintimeWatchWidgetBundle: WidgetBundle { + var body: some Widget { + TraintimeScheduleWidget() + TraintimeCourseNameWidget() + TraintimeCourseTimeLocationWidget() + TraintimeTodayScheduleWidget() + } +} diff --git a/watchOS/Widget/TraintimeWatchWidgetExtension.entitlements b/watchOS/Widget/TraintimeWatchWidgetExtension.entitlements new file mode 100644 index 00000000..1783c1ef --- /dev/null +++ b/watchOS/Widget/TraintimeWatchWidgetExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.xyz.superbart.xdyou + + + diff --git a/watchOS/apple_watch_technical_overview.md b/watchOS/apple_watch_technical_overview.md new file mode 100644 index 00000000..1217ea01 --- /dev/null +++ b/watchOS/apple_watch_technical_overview.md @@ -0,0 +1,745 @@ +# XDYou Apple Watch 技术说明 + +本文面向维护者,说明手机与手表的数据边界、同步协议、缓存、界面交互和验证 +入口。功能概览与使用方式见 [Apple Watch README](README.md)。 + +## 总体架构 + +Apple Watch 功能采用 **iPhone 主数据源 + 原生 watchOS Companion App** +架构。手机负责登录、访问校园系统、合并业务数据和生成课表;手表不保存账号 +凭据,也不直接访问学校接口,只展示手机已经展开到具体日期和时间的日程。 + +```mermaid +flowchart LR + A["Flutter 课程、考试、实验与设置"] --> B["完整学期快照"] + B --> C["iPhone 原生同步层"] + C --> D["WatchConnectivity"] + D --> E["Watch 同步协调器"] + E --> F["WatchScheduleStore"] + F --> G["Watch SwiftUI 界面"] + F --> H["App Group"] + H --> I["WidgetKit 小组件"] +``` + +系统分为五层: + +1. **Flutter 业务层**:课程、自定义课程、考试、实验、学期、周次、提醒和 + App 语言的唯一事实来源; +2. **快照层**:把重复课表展开成带绝对起止时间、节次、颜色和类型的完整 + 学期 JSON; +3. **iPhone 通信层**:保存最新完整快照、计算语义版本,并按手表请求裁剪 + 当天、近 14 天或学期分页; +4. **Watch 状态层**:负责三阶段同步、校验、范围合并、持久化和派生索引; +5. **展示层**:Watch App 与 Widget 只读取已经校验并安装的数据。 + +课表数据单向流动。手表发往手机的是同步请求、范围、分页偏移、刷新/请求标识、 +账号代次和已完整安装的版本号,不会回传本地完整课表。 + +## 数据归属与模块边界 + +| 数据或状态 | 写入方 | 读取方 | +| --- | --- | --- | +| 原始课程、考试、实验与设置 | Flutter | 快照构建层 | +| 完整学期快照与语义版本 | iPhone 原生层 | Watch 通信层 | +| 当前可见课表与三级缓存 | `WatchScheduleStore` | Watch 各视图 | +| 排序、按日分组和五段课程 ID 索引 | `WatchScheduleStore` | 列表、日、周、月视图 | +| 月份网格和三页预热窗口 | `MonthCalendarCache` | 月视图 | +| 日视图卡片高度 | `DayCourseLayoutTracker` | 日视图纵向导航 | +| 页面位移、表冠会话和吸附状态 | 对应 SwiftUI View | 仅对应页面 | +| Widget 课表、语言与交互状态 | App Group | Widget Extension | + +主要目录: + +| 路径 | 职责 | +| --- | --- | +| `lib/repository/watch/` | 构建完整学期快照并监听手机数据变化 | +| `ios/Runner/WatchConnectivityManager.swift` | iPhone 快照、版本、范围裁剪、回复和 `WatchSyncApiImplementation` 桥接实现 | +| `ios/Runner/PhoneWatchQueuedScheduleTransport.swift` | 后台队列请求转发与关联 | +| `watchOS/Connectivity/WatchConnectivityManager.swift` | Watch 三阶段同步状态机 | +| `watchOS/Storage/WatchScheduleStore.swift` | 快照安装、缓存、索引和公开状态 | +| `watchOS/Storage/DayCourseLayoutCache.swift` | 卡片高度采样、双向位移换算和可暂停持久化 | +| `watchOS/Shared/WatchSyncSupport.swift` | 协议键、同步范围、schema、语言映射、日期和文本纯函数 | +| `watchOS/Shared/WatchWidgetShared.swift` | App Group、缓存编码、语言读取与 Widget 交互状态 | +| `watchOS/Shared/WatchSchedulePresentation.swift` | 范围合并、课程焦点、概览和时间线状态 | +| `watchOS/Shared/WatchWidgetDesignTokens.swift` | 小组件主要文字层级 | +| `watchOS/Views/RootScheduleView.swift` | 顶层路由、提示、悬浮控件和详情层 | +| `watchOS/Views/OverviewScheduleView.swift` | 今日摘要、当前与下一项、本周待办统计 | +| `watchOS/Views/CourseListView.swift` | 整学期自然日分组列表 | +| `watchOS/Views/DayScheduleView.swift` | 单日课程、纵向浏览与横向翻日 | +| `watchOS/Views/WeekScheduleView.swift` | 周次边界、七日网格与色块命中 | +| `watchOS/Views/MonthScheduleView.swift` | 月份网格、日程标记与日期提交 | +| `watchOS/Views/MonthCalendarData.swift` | 月份轻量模型、标记组装和三页内存缓存 | +| `watchOS/Views/CourseViews.swift` | 共用课程卡片和顶层详情页 | +| `watchOS/Views/CalendarPagingSupport.swift` | 日/周/月共用分页、吸附和表冠桥接 | +| `watchOS/Views/InteractionAwareScrollView.swift` | 列表滚动观察和顶部保护 | +| `watchOS/Views/WatchInteractionSupport.swift` | 可取消任务、按压状态、完成去重、触觉和表冠连续会话 | +| `watchOS/Views/WatchOnboardingView.swift` | 实操引导步骤、输入桥、顶层遮罩、欢迎页和动作动画 | +| `watchOS/Views/Onboarding/WidgetOnboardingView.swift` | 五页组件指南、分页、自动播放和完成生命周期 | +| `watchOS/Views/Onboarding/WidgetIntroPage.swift` | 内容页容器、黑色过渡页、课程和形态介绍 | +| `watchOS/Views/Onboarding/WidgetInstallGuideView.swift` | 系统添加小组件的三步示意 | +| `watchOS/Views/Onboarding/WidgetPreviewView.swift` | 截图资源映射、原比例展示和辅助功能说明 | +| `watchOS/Assets.xcassets/WidgetGuide/` | 组件指南使用的 18 张固定截图裁切素材 | +| `watchOS/Widget/` | 表盘 Complication、Smart Stack 与时间线 | + +通信层不保存 SwiftUI 页面状态,View 不直接解析 WatchConnectivity 字典; +Store 不持有页面手势和动画状态。 + +## 状态与任务生命周期 + +`RootScheduleView` 只持有跨页面路由和短生命周期 UI 状态。异步任务按职责分组: + +- 悬浮控件组:按钮自动隐藏、缓存提示和引导完成提示; +- 引导组:章节过渡、首轮渲染准备和表冠停止判定; +- 月视图预热组:不可交互月视图的短时挂载。 + +根页面消失、引导重新开始或引导完成时,通过统一取消入口结束对应 Task 并清空 +引用。引导正常推进和错误恢复共用同一个路由安装函数,模式、教学日期、日期 +选择器、详情课程和悬浮控件状态会作为一组提交,避免两条路径形成不同状态。 + +`WatchScheduleStore` 是可见课表和派生索引的唯一写入者。快照安装、索引恢复、 +课程列表入口和月份预热都由 Store 的明确入口完成;视图只调用查询方法。可恢复 +错误统一带阶段标签写入系统日志,用户提示与缓存回退仍由发生错误的业务入口 +决定。 + +## 手机端课表生产 + +### iPhone 接入与文件职责 + +手机提供日程生产和传输入口,手表按需取得当天、近 14 天及完整学期。课表变化 +经 400 毫秒防抖后同步,语言使用独立监听;清缓存、退出和重新登录通过账号 +代次及串行写入联动,防止旧刷新恢复已清空的数据。 + +| 文件 | 职责 | +| --- | --- | +| `lib/repository/watch/watch_schedule_snapshot.dart` | 将学校课表、自定义课程、考试与实验展开成完整学期,携带教师、地点、节次、颜色、座位、时区和提醒参数 | +| `lib/repository/watch/watch_schedule_sync_service.dart` | 监听数据与语言,管理防抖、任务代次、串行发送及清空/恢复同步;仅在 iOS 启动 | +| `lib/main.dart` | 启动 `WatchScheduleSyncService` | +| `lib/controller/theme_controller.dart` | 提供实际生效的语言标识,将“跟随系统”解析为明确语言 | +| `lib/controller/homepage_controller.dart`、`lib/repository/widget_state_sync.dart`、`lib/page/setting/groups/core_section.dart` | 在刷新、登录状态和清缓存流程中调用同步服务;用会话修订阻止迟到刷新恢复旧账号数据;清理包括 `CustomClassesV2.json` 在内的共享文件 | +| `pigeon_bridge/save_to_groupid.dart` | 定义 `WatchSyncSwiftApi` 的课表同步、语言同步和清空接口 | +| `lib/bridge/save_to_groupid.g.dart`、`ios/Runner/SaveToGroupID.g.swift` | 由同一 Pigeon 定义生成 Dart/Swift 通道;接口变化时同步生成 | +| `ios/Runner/WatchConnectivityManager.swift` | `PhoneWatchConnectivityManager` 持久化完整快照、计算语义版本、裁剪范围与分页并发布状态;同文件的 `WatchSyncApiImplementation` 将 Pigeon 调用转发给管理器 | +| `ios/Runner/PhoneWatchQueuedScheduleTransport.swift` | 验证后台请求并在回复中附带刷新/请求标识,复用即时通道的回复生成逻辑 | +| `ios/Runner/AppDelegate.swift` | 激活 WCSession、注册文件共享与 Watch 同步两套 Host API,并配置前台通知显示 | +| `ios/Runner/ApiImplementation.swift` | 实现原有 `SaveToGroupIdSwiftApi`,负责共享文件写入与删除;Watch 同步使用独立的 `WatchSyncApiImplementation` | +| `ios/ClasstableWidget/ClasstableWidget.swift`、`ios/ClasstableWidget/EventItem.swift` | iPhone 小组件的数据读取、时间线和课程行展示;时间线包含当前节点及当天未来课程边界,午夜重新请求,日期取自时间线节点 | +| `ios/Runner.xcodeproj/project.pbxproj` 与 `ios/Runner.xcodeproj/xcshareddata/xcschemes/` | 声明 Watch App/Widget target、依赖、嵌入关系及 Runner、TraintimeWatch 构建入口 | +| `lib/repository/preference.dart`、iOS/Watch entitlements、`ios/Runner/Info.plist` | 配置共享容器与应用关联;App Group、Bundle ID 和 Companion 标识需与工程配置一致 | + +手机使用既有页面和数据源,设置入口调用同步服务处理状态,不传递校园登录 +凭据。课程提醒由手机已有通知系统调度。iPhone 小组件仍读取其共享文件,Watch +App 与 Watch Widget 使用独立的快照缓存;两种数据格式不能互相替代。 + +### 完整快照 + +Flutter 将以下内容转换为统一的 `WatchCourseOccurrence`: + +- 学校课程; +- 自定义课程; +- 考试及座位号; +- 物理实验和其他实验; +- 学期起点、当前周次和数据覆盖范围; +- 时区、提醒提前时间和与手机端一致的课程颜色。 + +日程类型包括 `course`、`exam`、`physicsExperiment` 和 +`otherExperiment`。当前快照 schema 为 4,Watch App 与 Widget 共同使用 +`WatchWidgetShared.supportedScheduleSchemaVersions` 校验支持范围。 + +### 语义版本 + +iPhone 对完整学期 JSON 按键排序后计算 SHA-256,仅移除 +`generatedAtEpochMs`。课程、日期、节次、颜色、周次、提醒、地点、教师、座位 +或其他保留字段变化时都会产生新版本。语言通过独立消息同步,不属于快照哈希。 + +手表请求携带已完整安装的版本号和账号代次: + +- 版本与账号代次都一致:手机返回 `scheduleUnchanged` 和轻量设置,不发送课表正文; +- 版本、账号代次不匹配或手表没有完整版本:执行当天、14 天、学期三阶段同步。 + +只有完整学期所有分页安装成功后,手表才持久化新版本。局部缓存不能代表完整 +课表,因而当天或 14 天阶段不会提前更新版本号。 + +## 渐进同步 + +### 阶段顺序 + +```mermaid +sequenceDiagram + participant P as iPhone + participant W as Watch + participant S as WatchScheduleStore + + W->>P: today + installedVersion + accountGeneration + alt 版本与账号代次一致 + P-->>W: unchanged + language + W->>S: 保留缓存并结束 + else 需要完整更新 + P-->>W: 当天快照 + W->>S: 只替换当天范围 + W->>P: fourteenDays + P-->>W: 14 天快照 + W->>S: 只替换 14 天范围 + W->>P: semester offset=0 + loop 尚有分页 + P-->>W: 学期分块 + nextOffset + W->>S: 合入内存缓冲 + W->>P: semester nextOffset + end + W->>S: 原子安装完整学期并保存版本 + end +``` + +同一轮三个阶段必须属于同一版本和账号代次。中途版本变化时,Watch 丢弃 +学期缓冲并从当天重新开始,避免把两份课表拼接在一起。 + +### 通信通道 + +| 通道 | 用途 | +| --- | --- | +| `sendMessage` | 前台可达时的低延迟请求与回复 | +| `transferUserInfo` | 实体表即时失败后的后台队列 | +| `updateApplicationContext` | 最新 14 天快照、版本和语言的启动兜底 | + +即时和后台通道共用 iPhone 的回复生成函数,范围过滤、版本判断和分页语义保持 +一致。Watch 只保留一个 `PendingScheduleRequest`,绑定请求 ID、范围和偏移; +收到回复时先核对刷新/请求 ID,再认领并清除该请求,两通道只消费一次。回复 +范围必须匹配当前请求,有后续页时偏移必须向前推进;末页不能回退,但允许 +偏移不变的空末页。当天和 14 天不能分块。 + +单轮渐进同步最长等待 12 秒,与启动等待新回复的 3 秒窗口独立管理。12 秒 +超时且无法恢复 Application Context 时,失败处理清空学期缓冲;若此时等待 +后续学期分页(`offset > 0`),该请求的迟到回复只能触发从当天重新同步, +不能把后半段课程安装为完整学期。首个学期页及当天、14 天阶段的迟到回复仍可 +继续处理;旧刷新和已消费请求的回复直接忽略。Context 恢复成功时保留继续 +接收本轮回复的路径。模拟器不建立 `transferUserInfo` 队列,即时发送失败时 +使用 Application Context 兜底。 + +### 启动与离线 + +Watch App 每次打开或回到前台都会请求同步: + +- 3 秒内没有手机回复且存在有效学期缓存:继续展示缓存,并显示可轻点关闭、 + 15 秒自动消失的紧凑提示; +- 没有任何可展示学期日程:显示“请打开手机 XDYou”页面; +- 同步阶段失败或超时:保留原页面和原缓存; +- 每个阶段成功后立即替换该阶段覆盖范围,不清除范围外日期。 + +## 启动准备与交互性能 + +启动工作按“数据依赖是否已经具备”分成三层,避免把可以提前完成的计算推迟到 +用户第一次进入页面时: + +| 时机 | 完成的工作 | 不在此阶段执行的工作 | +| --- | --- | --- | +| `WatchScheduleStore` 初始化 | 解码三级课表缓存、校验并恢复派生索引、计算列表入口与教学日期、预热当前月份和教学月份的相邻三页 | 网络请求、SwiftUI 页面创建 | +| 根视图首帧之后 | 激活 WatchConnectivity、发送带已安装版本号的同步请求、短暂预挂载不可交互的月视图渲染树 | 整学期排序、JSON 编码 | +| 已知表盘宽度之后 | 校验日卡片布局签名并恢复卡片高度 | 与当前宽度不匹配的布局缓存 | + +课表索引安装后,列表、日、周、月和教学入口只读取数组或日期字典,不在手势、 +表冠和动画逐帧路径中扫描整学期。教学日期在安装索引时一次算好;月份窗口在 +Store 初始化或新课表安装时准备。只有依赖真实 SwiftUI 几何的卡片高度和控件 +坐标必须等页面完成布局后采样。 + +月份缓存除单月日期模型和五段课程标记外,还以中心月为键保存已经组装好的 +前、中、后三页原子窗口。根视图首帧完成后会以近乎透明、禁止命中且不申请 +表冠焦点的方式短暂挂载一次真实 `MonthScheduleView`,提前建立 +`NavigationStack`、分页器、Canvas 和工具栏的首次渲染管线,随后自动移除。 +首次打开月视图直接读取已准备的数据窗口和已经建立的渲染管线。 + +派生索引和卡片高度先立即更新内存,再由可取消的后台任务编码 JSON。连续收到 +当天、14 天和学期数据时,仅最新代次允许写入 Defaults;过期任务即使完成也 +不会覆盖新索引。阶段数据立即更新内存,JSON 编码移出交互线程;最终 Data 写入 +仍由主线程提交。 + +## 持久化缓存 + +缓存按数据生命周期拆分,不能简单合成一个大对象。课表正文需要阶段级原子 +替换;派生索引可重建;卡片高度还受语言和表盘宽度影响。分层存储能让单项 +损坏只触发该层重建。 + +### 缓存清单 + +| 缓存 | 存储位置 | 内容 | 失效或重建条件 | +| --- | --- | --- | --- | +| 当天课表 | Standard + App Group | 当天完整快照 | 新当天阶段完成 | +| 近 14 天课表 | Standard + App Group | 14 天完整快照 | 新 14 天阶段完成 | +| 完整学期课表 | Standard + App Group | 权威学期快照 | 学期全部分页完成 | +| 已安装版本 | Standard | iPhone 语义版本 | 完整学期缺失或新学期安装 | +| 展示派生索引 | Standard | 排序 ID、自然日分组、五段标记、列表入口 | 来源快照不匹配或 schema 变化 | +| 日卡片布局 | Standard | 课程 ID 到实测卡片高度 | 当前快照修订、语言、宽度、动态字体或粗体设置变化 | +| 月份预热窗口 | 运行时内存 | 最多八个中心月的三页网格与标记 | 课表、系统日历或时区变化;超限淘汰最近最少访问窗口 | +| Widget 交互状态 | App Group | 综合组件当前课程 ID 与下一节预览截止时间 | 五分钟、当前下课、下一项上课或当前 ID 改变 | + +私有缓存键统一定义在 `WatchPersistentCacheKey`;Codable Data 的编码和读取统一 +经过 `WatchCacheCoding`。课表正文的三个共享键及其稳定读取顺序由 +`WatchWidgetShared` 唯一维护,Store 与 Widget 读取同一组键名和 schema 范围。 + +### 原始课表恢复 + +启动时每个范围分别解码 Standard 与 App Group 两份候选,选择修订较新的有效 +副本;坏数据只影响自身。`WatchScheduleResolver` 按 `sourceRevision` 排序,旧 +协议缺少修订号时才用生成时间。同一修订号中完整学期具有最终权威性。 + +同学期的新当天或 14 天快照只覆盖自己的明确范围,保留范围之外的课程;范围 +内空数组也代表取消全部日程。不同学期不拼接。每段覆盖范围保留自己的有效期, +局部更新不能延长其他日期旧数据的有效期。概览复用索引安装时计算好的合并结果。 + +清空和退出通过独立协议传递 `scheduleCleared`、`signedOut`、`stateRevision` +和 `accountGeneration`。旧修订或旧账号回复不能恢复已清空课表;本地索引、卡片 +高度、Widget 预览和教学目标同时失效。完整学期缺失时会清除孤立版本号,防止 +只有版本、没有正文却向手机误报“无需更新”。 + +### 持久化派生索引 + +`WatchScheduleStore` 在快照安装时一次生成: + +- 稳定排序后的课程数组; +- `Date -> [WatchCourse]` 自然日索引; +- 课程列表分组和首次定位日期; +- `courseID -> WatchCourse` 映射; +- 每日五个两节区间对应的课程 ID。 + +新建和恢复最终都经过 `installVisibleScheduleIndex`,确保所有派生字段原子安装。 +派生索引 schema 为 2,来源身份包含原快照 schema、生成时间、单调修订号、范围、 +课程数量、系统日历和时区。全部匹配才复用;不匹配的索引会从原始快照重建。 +iPhone 的语义版本是“课表是否变化”的唯一依据,来源身份仅防止本地文件错配。 + +恢复流程按职责拆分:先校验缓存与原始快照身份,再恢复课程 ID 和自然日索引, +最后恢复课程列表入口。课程顺序、自然日归属和五段节次标记必须与原始课表 +一致;重复日期、课程遗漏、错误顺序或不匹配的标记会使恢复失败。跨自然日时 +只重算列表入口;任一结构校验失败则整体回退到原始课表重建,不安装部分索引。 +排序和校验共用 `WatchScheduleResolver.precedes` / `isSorted`;每日标记的生成 +与恢复校验共用 `WatchScheduleStore.makePeriodCourseIDs`,避免两套规则漂移。 + +课程列表入口、教学示例日期和索引课程数量也在安装入口中一次更新,后续完整性 +检查和教学启动均为常量时间。派生缓存 JSON 在后台编码;主线程只负责安装内存 +值和提交最终 `Data`。 + +### 月视图运行时预热 + +`MonthCalendarCache` 管理两类生命周期不同的数据: + +- 日期网格依赖年月、系统日历和时区,可跨课表版本复用; +- 五段标记引用当前课表课程,课表索引变化时单独失效。 + +Store 恢复派生索引后立即预热当前月及前后两月。日视图选择日期变化时预热该 +日期对应的三页窗口;月视图只接收已经成组准备好的日期模型和标记字典。页面 +入场、横向拖动和表冠逐帧路径因此只读取内存,不执行整学期扫描、颜色转换或 +持久化编码。 + +缓存保留最近访问的八个中心月。淘汰窗口时,同时释放不再被窗口引用的单月网格 +和课程标记,最多保留 24 份单月数据。前台恢复或系统时区通知会校验日历环境, +变化后重建自然日索引和月份窗口,不改动原始课程时间戳。 + +### 日视图布局缓存 + +日视图使用课程真实卡片高度把连续课程索引换算成像素位移。高度缓存签名包含: + +- 当前可见快照 schema、修订号、生成时间和数量,不能只依赖旧完整学期版本; +- 手机同步的界面语言; +- 当前表盘内容宽度、Dynamic Type 大小和文字粗细; +- 布局缓存 schema。 + +相邻三页预先测量卡片,测量结果先合并到内存。尚未测量的卡片使用已测高度 +平均值估计;`refreshAverageMeasuredHeight` 在采样或恢复时更新,清空时失效, +交互中直接读取。手指或表冠逐帧操作期间暂停新的 JSON 编码,暂停期间新增测量 +也不能重新排队写盘;已开始的后台编码允许完成计算,但取消检查会阻止提交。 +停止交互后按 1.5 秒 +延迟合并写盘。页面离开取消任务,清空代次阻止后台编码结果重新写回。恢复高度 +时过滤非法数值。实现独立于 View,可以使用临时 Defaults 直接运行回归测试。 + +## Watch App 界面 + +三点按钮提供五种模式: + +1. **概览**:今日分类统计,最多显示当前和下一项,再补充本周安排摘要; +2. **课程列表**:按自然日分组浏览整学期日程; +3. **日视图**:浏览单日课程卡片; +4. **月视图**:浏览月份并选择日期; +5. **周视图**:显示第 1–10 节的七日色块网格。 + +课程、考试和实验使用手机传来的颜色。列表和日视图使用 24 小时制,地点与 +教师或考试座位号同行显示,超出宽度时尾部省略。 + +概览通过 `WatchOverviewSummary` 生成摘要。今日课程、考试、实验按日程条目 +计数,正在进行的项目仍属于未结束安排;全部结束后保留当天已完成的分类数量。 +已完成项数只在当天仍有剩余安排时补充,避免重复“今日安排已完成”。课程区域 +最多展示当前项与下一项,无当前项时仅显示下一项。教师或考试备注与位置同行,同一天的日期也不在每张卡片上重复。 + +今日最终结束时间只在它没有出现在当前/下一项卡片时补充。本周摘要提供剩余 +安排天数及待考、待实验数量,“待考/待做”只计算尚未开始的项目。今日计数要求 +当天完整覆盖;本周未来统计仅要求从当前时刻到周末完整覆盖,不因未同步已过去 +的日期而隐藏可靠的未来信息,也不把过期或有断档的缓存当作已知安排。 + +`WatchSchedulePresentation.weekCourses` 与 `weekIsComplete` 在一次查询中只计算 +一次周区间。Widget 的普通展示直接复用同一 Presentation,仅临时预览下一节时 +另建结果,避免同一个时间线节点重复扫描相同课表。 + +概览、课程列表和课程详情都使用 `InteractionAwareScrollView` 区分触摸和 +表冠输入。概览与详情等普通内容先测量自然高度;课程列表的 `LazyVStack` +保留系统 ScrollView 的原生尺寸提案,以便懒加载内容建立完整滚动范围。 +长列表和详情只观察系统 ScrollPhase,不安装额外 DragGesture,保证真实内容 +始终跟手;只有不足一屏且系统可能漏报 `.tracking` 的概览教学会启用轻量触摸 +兜底。详情覆盖周视图时,焦点直接交给内部原生 ScrollView,关闭后再由周视图 +恢复焦点。 + +### 实操引导与组件指南 + +两种教程分别管理输入与展示:实操引导观察真实页面操作,小组件指南展示固定 +示例并允许分页阅读。`RootScheduleView` 统一管理入口和完成状态。 + +| 组件 | 职责 | +| --- | --- | +| `WatchOnboardingStep` | 教学顺序、目标页面、文案和期望操作 | +| `WatchOnboardingInputBridge` | 接收实际操作并判定成功、错误或等待 | +| `WatchOnboardingOverlay` | 实操遮罩、进度、动作与结果动画 | +| `WidgetOnboardingView` | 五页阅读流程、轮播、表冠和结束计时 | +| `RootScheduleView` | 教学路由、示例日期、入口来源和完成持久化 | + +#### 实操流程 + +本学期存在教学日程且尚未完成引导时进入欢迎页。教学日期在 Store 安装索引时 +选定:优先课程较多的日期,同数量时选离今天较近的一天。没有日程则先显示手机 +同步提示,收到可用日程后再进入欢迎页。数据准备与首屏渲染完成后允许轻点开始。 + +教学顺序为概览、课程列表、日视图、周视图和月视图,覆盖真实点击、手指浏览、 +表冠浏览、日期选择和课程详情。最后练习按住右下角三点按钮三秒。每个步骤 +对应唯一 `WatchOnboardingOperation`,触摸与表冠不能互相代替。 + +欢迎页和五个视图分段页使用黑色背景与 `WatchOnboardingSweepingLightText` +扫光。欢迎页不显示章节进度;分段页显示 `1/5` 至 `5/5`,等待轻点继续。实操 +提示使用实际页面上的固定标题、蓝色进度和动作说明。开始输入时提示淡出, +抬手或滚动停止后判定;成功显示绿色对号,错误显示白色错号并恢复当前教学路由。 +最终长按练习成功后衔接组件指南,实操结束时尚不写入完成标记。 + +浮动控件的点击目标取实际全局 frame。周视图只接受当前页且至少 80% 面积 +位于视口内的课程色块,再冻结目标中心;日、周、月标题箭头共用固定响应中心。 +根视图把全局中心转换为教学视口坐标,表冠输入不会被误当成色块点击。 +实操说明、动作提示和结果层禁止命中,由底层真实页面执行操作并旁路上报。 + +| 系统版本 | 原生滚动完成判定 | 教学触摸视觉 | +| --- | --- | --- | +| watchOS 11 及以上 | `ScrollPhase` 区分 tracking/interacting/idle,并保留触摸来源标记 | 概览使用弹性偏移;列表通过 `ScrollPosition` 连续定位 | +| watchOS 10 | 触摸结束兜底;具备来源标记且无程序化视觉代理时,使用真实位移和 0.35 秒静止窗口补报表冠 | 概览使用弹性偏移;列表保留系统滚动 | + +指定教学步骤使用触摸视觉代理时,禁用同一方向的原生位移,避免双重推动。 +`WatchInputCompletionGate` 使系统 idle 和 0.08 秒触摸兜底仅提交一次完成。 +教学上下文变化、页面离开和系统取消均取消旧任务并推进代次。watchOS 10 的 +来源判定属于兼容推断,应按系统版本纳入后续回归范围。 + +`GestureState` 处理没有 `onEnded` 的取消路径。分页取消只回收位移,不提交 +教学或额外翻页。表冠使用单调时钟划分会话,有效输入才提交教学,避免系统 +校时影响方向判断。 + +#### 三秒长按与入口 + +三点按钮和黑色欢迎页复用 `makeWatchHoldFeedbackTask` 与 +`WatchHoldFeedbackPulse`。按住 0.3 秒后开始触觉脉冲,在三秒内由轻到重、 +强度由 0.25 增至 1,间隔由 0.28 秒缩至 0.12 秒,满三秒立即触发,无需抬手。 +这是离散脉冲序列,实际触感由设备与系统控制。 + +按压超过 36 pt 位移、系统取消、页面离开或进入后台都会停止计时与反馈;同一轮 +移回有效范围不会重新计时。正常浏览时三点按钮短按打开视图列表,长按重启完整 +引导;实操要求长按时,误点只进入错误反馈,不弹出视图列表。 + +黑色欢迎页任意位置长按可直接进入组件指南,此入口无需等待教学预热完成。 +短于 0.3 秒的有效轻点在准备完成后开始实操;已开始反馈但未满三秒松手则留在 +欢迎页。满三秒的任务与抬手兜底共用完成门,避免重复跳转。欢迎页提供相应的 +VoiceOver“小组件使用指南”操作,三点按钮也提供重新打开引导的辅助功能操作。 + +视图列表的“使用指南”分组同时提供“App 操作教程”和“小组件使用指南”。 +后者不需要真实课表。入口以 `WidgetOnboardingEntry.fullTutorial` 或 `.guide` +区分:完整教程及欢迎页快捷入口在指南结束后写入 +`XDYouWatchCompletedOnboardingV1`;单独阅读指南不修改该标记。 + +#### 五页组件指南 + +| 页 | 内容与操作 | +| --- | --- | +| 1 | 黑色扫光“还有一个更快的方法”;轻点后使用 0.38 秒水平过渡滑入第 2 页 | +| 2 | 综合课表圆形、表角和长方形的课中示例,以及圆形和长方形的次日示例 | +| 3 | 选择圆形、表角或长方形,展示所选形态的组件、状态及用途说明 | +| 4 | 循环演示长按表盘、编辑复杂功能和选择 XDYou,并显示对应操作说明 | +| 5 | 黑色“教程结束 / 开始愉快的使用吧”,前台停留两秒后自动返回概览 | + +左右滑动、数码表冠、分页点和辅助功能调整操作共用页码选择逻辑。第一页的 +轻点过渡临时锁住输入,结束后交还系统分页;减少动态效果开启时直接换页。 +第 2–4 页的内容与分页点顺序布局,标题、图片区域、说明各自有有限高度,避免 +系统分页安全区使底部内容重叠。正文采用 `WidgetTutorialLayout` 的共享参数, +第 4 页底部说明按 `3/5` 比例缩小并释放展示区空间。 + +卡片及安装步骤每三秒循环。手动切换重置下一次播放等待,自动切换不会结束 +循环;第 3 页圆形有八个示例、长方形七个、表角一个综合课表课中示例。当前页 +选中且 App 在前台时才播放;减少动态效果、VoiceOver 或第一页过渡期间暂停。 +第五页倒计时依据实际页码和场景状态,不能用相邻预加载页的 `onAppear` 开始; +离开或进入后台取消倒计时,完成门保证只调用一次结束回调。 + +示例使用 `Assets.xcassets/WidgetGuide/` 中的 18 张原图裁切 PNG,保留截图 +文字、图标和颜色。独立组件共用 164×70 的参考画布,按剩余视口等比缩放; +安装示意使用 164×134 参考画布。图片映射、课程示例和辅助功能说明集中在 +`WidgetPreviewView.swift`,不读取实时课表、不写缓存、不建立 Widget 时间线。 +截图内文字保持原图,周围说明和语音描述使用当前语言。改变真实组件内容时 +需同时检查示例图片和语义说明是否仍然合适,不能仅更新代码标签。 + +教程结束回到概览顶部,显示可轻点关闭、15 秒自动消失的提示。结束指南只记录 +教程阅读,不判断用户是否已添加系统小组件。通过实际小组件打开 App 会暂停 +本次教程并进入概览,不将尚未完成的教程永久标记为完成。 + +#### 教学数据与预热 + +Store 初始化已准备课程列表索引、示例日期和月份窗口;欢迎页确认数据和首屏 +材质可用。加载期间在黑色页面后挂载不可交互的材质及动作首帧,不循环运行 +不可见动画。同一视图在步骤变化时保持 identity,只更新教学状态。 + +首次进入课程列表章节且底层数据尚未准备完成时,分段页显示渲染提示,完成后 +给出触觉和文字确认。章节页立即插入、淡出移除,底层路由在黑色覆盖提交后 +变化。同步删除教学目标、清空示例日期或清空课表时,旧详情和坐标失效,并 +重新检查教学是否可用。 + +### 共用分页基础设施 + +日、周、月视图复用以下实现: + +- `CalendarHorizontalPager`:稳定的前、中、后三页容器和触摸轴锁定; +- `horizontalDragMotion`:按位移、预测位置和末速度决定目标页; +- `calendarCrownPageMotion`:把表冠刻度与速度换算成横向像素; +- `normalizedContinuousPageOffset`:完整跨页后的无动画换底; +- `horizontalPageSnap`:生成目标位置和吸附时间; +- `CalendarPagingCrownInputModifier`:统一表冠范围、步长、灵敏度和系统声音; +- `CalendarCrownIdleCoordinator`:位于 `WatchInteractionSupport.swift`,统一 `onIdle` + 确认与实体表漏回调兜底,可独立运行状态机测试。 + +页面只保留自己的业务差异:日视图的纵向卡片阶段、周视图的学期边界、月视图 +的月份模型和日期提交。共享组件不包含课程数据,也不改变页面布局。 + +### 日视图 + +- 前一天、当前日和后一天预渲染,横向触摸与页面位移保持 1:1; +- 同一个触摸层先识别横向或纵向,锁定后保持单一轴向; +- 纵向触摸和表冠共用同一内容偏移与课程索引; +- 多项日程先纵向浏览,达到末项后转入连续横向翻日; +- 零项或一项日程直接横向翻日; +- 触摸松手支持惯性、阻尼、边界回弹和末项安全留白; +- 横向完整跨屏后立即换底,持续旋转表冠可连续翻日; +- 日期标题可打开独立月份页;日视图不打开课程详情。 + +卡片高度预热和持久化使相邻有课日期进入屏幕前已经具备布局数据;页面偏移 +变化不会重新排序课程或查询整学期快照。 +惯性帧差使用单调时钟,避免系统校时造成跳变。延迟恢复表冠焦点前核对页面 +代次和日期选择层状态,页面退出后旧回调不能重新获取焦点。 + +### 周视图 + +- 周标题优先使用手机同步的周次参考; +- 可浏览范围限制在手机完整学期首周与末周; +- 当前日期列使用淡色高亮; +- 手指、标题箭头和表冠共用横向分页; +- 点击色块打开顶层课程详情,点击空白区域恢复悬浮控件; +- 学期边界使用阻尼位移和触觉反馈,不提交无效周次。 + +### 月视图与日期选择 + +月视图与日视图日期入口共用 `MonthScheduleView`: + +- 页面是根视图中的独立全屏层,从底部进入和退出; +- 星期栏、月份标题和网格属于同一转场; +- 手指、表冠和标题箭头均可翻月; +- 当前月和相邻月份构成轻量三页窗口; +- 单月使用一个异步 Canvas 绘制日期、网格、今天红框和五段标记; +- 点击坐标换算为 7 列日期索引,不创建 35/42 个按钮; +- 普通日期为亮白色,选中日期为蓝色加粗; +- 今天使用透明红色粗框;有日程时红框同时包住日期和五段标记; +- 五段依次代表第 1–2、3–4、5–6、7–8、9–10 节,有课使用课程色, + 空闲段使用暗白色,整天无日程不绘制五段。 + +### 课程详情与悬浮控件 + +课程详情位于 `RootScheduleView` 最上层,从底部弹出,不使用系统 Sheet。 +内容使用原生 ScrollView,手指和表冠均可滚动;关闭后再把表冠焦点交还周视图。 + +刷新按钮位于右上方,模式按钮位于右下方。滚动或转动表冠时自动隐藏;等待 +手机或正在同步时保持显示。同步完成提示和缓存提示不阻塞页面操作。 + +## 国际化 + +Watch App 与 Widget 支持: + +- 简体中文 `zh_CN`; +- 繁体中文 `zh_TW`; +- 英语 `en_US`。 + +手机 App 当前实际生效语言是首选来源。Watch Store 把语言写入 App Group, +App 与 Widget 共用。目录、状态和周次通过 `watchLocalizedString` 读取 String +Catalog;日期与星期使用注入的 Locale。课程、教师和地点属于用户或学校数据, +保持原文。 + +`WatchSyncSupport.swift` 同时编入 iPhone、Watch App 与 Widget,集中维护协议字段、 +三阶段范围、schema 支持范围和 `WatchLanguage`。语言代码先按脚本再按地区归一化, +例如 `zh-Hans-TW` 保持简体,`zh-Hant-CN` 保持繁体;未知语言不会误判成英语。 +Flutter 使用独立语言 effect 与同一串行写入队列,清空课表或退出后仍能更新语言, +语言变化只更新语言消息。去重发生在实际发送前,避免快速切换时遗漏最终选择。 + +`watchLocalizedFormat` 与资源查找共用手机指定 Locale;`.lproj` 资源包只解析一次, +最终文案按当前语言读取。考试快照中由生成器添加的“座位 ”前缀在显示时本地化, +原始 JSON、座位号和其他学校备注保持原文。缺少 App Group 时语言可落入本地缓存。 +`tools/audit_watch_localizations.py` 检查目录翻译与格式参数,并检查普通 Swift +本地化字符串字面量是否有资源,先解码换行等转义再匹配键。动态键、插值、raw +字符串和多行字面量需人工核对,脚本不能替代界面和 VoiceOver 的三语言验证。 + +## 表盘 Complication 与 Smart Stack 小组件 + +四个组件共用 `WatchSchedulePresentation` 和同一缓存范围判定: + +| 组件 | 内容职责 | +| --- | --- | +| 综合课表 | 课程、时刻与地点按尺寸取舍;唯一支持表角的组件,上课期间按形状显示进度 | +| 课程名称 | 当前焦点课程名称与状态 | +| 时间地点 | 与名称组件保持同一课程,显示上课、下课时间及地点,仅上课期间显示进度 | +| 日程概览 | 今日剩余安排、最后结束时间和本周总数;今日结束后以次要文字提示下一次安排 | + +四种组件支持单行、圆形和长方形,只有综合课表支持表角。综合组件正常显示当前 +课程,当前无课则选择下一项。单行每次只显示一个时刻:课前为“上课 HH:mm”, +上课中为“下课 HH:mm”;长方形同时显示起止时间,进度条放在时间行与地点行之间, +保留卡片内边距。长方形时间以单行范围显示,时刻与位置/教师行统一使用 16 pt +常规字重和主前景色;综合课表和时间地点的标题为 12 pt,起止语义保留在辅助 +功能标签中。 +所有尺寸都不显示倒计时。 + +圆形的综合课表与时间地点组件采用系统 `accessoryCircular` 开口圆环和进度圆点: +顶部为“下课”,中央为下课时间,底部开口放地点。课前隐藏圆环,按“上课、时间、 +地点”三行显示;跨日提示并入第一行。表角上课中外侧仅显示课程名, +内侧为系统弧形进度;其余时段外侧显示 `08:30`、`明天08:30` 或 `9/9 08:30`, +内侧为下一节的“精简位置 · 课程名”。位置和课程名采用 12 pt 粗体,位置排在 +前面以优先保留完整教室号,空间不足时从尾部省略课程名;时间采用 10 pt 常规字重。 +不在表角叠加上下课标签、百分比或第二个时间,也不无限缩小字号。 + +无圆环时,圆形组件的主要文字共用 12 pt 半粗体及同一缩放下限,辅助文字为 +8 pt。位置与时刻同属主要信息,有无圆环都使用相同的 12 pt 半粗体和主前景色, +超长地点保持单行省略。 +圆形、单行和表角组件通过 `compactLocation` 省略位置中的“信远”并清理首尾空白, +保留罗马数字与教室号(`信远 I-105` → `I-105`);完整位置用于长方形组件和 App。 +圆形分支在最外层统一设置 `widgetAccentable`,课程、时刻、概览及所有 +空状态都进入同一表盘着色组;全彩模式统一使用系统前景色和单色图标,两个 +Gauge 都继承该前景色。 + +只有焦点课程正在上课时才显示课程进度。日程概览的 `summaryDate` 始终为当前 +日期,当天结束后保留“今日已下课”,下一次的日期与时刻作为次要信息。本学期 +结束显示“本学期结束,开心玩耍吧!”。 + +综合组件的下一节预览只影响自身:绑定当前课程 ID,并在五分钟、当前课程下课 +或下一项上课中最早的时刻失效。名称和时间地点组件始终共享正常焦点,避免分 +组件互相矛盾。Bundle 与缓存刷新入口使用同一组四个已注册 kind。 + +全部小组件统一使用 `WatchWidgetDestination.overview.url`。根视图的 `onOpenURL` +关闭课程详情和日期/模式选择层,切回概览,并重建概览滚动容器以回到页面顶部。 +旧时间线保留的 `course`、`day` URL 也映射到概览;无课程的空状态同样提供该链接。 +切换箭头通过 App Intent 在组件内切换课程。 +小组件入口暂停本次教学,不将教学永久标记为完成。 + +Timeline 节点覆盖 15 分钟状态阈值、上课、下课、自然日和缓存失效边界,并在 +上课期间每五分钟增加进度节点;实际更新由 WidgetKit 调度。进度均采用有限 +数值:圆形为系统 Gauge,长方形由 Capsule 绘制,表角将 Gauge 放入 +`widgetLabel`,外侧文字使用 `widgetCurvesContent`,由 WidgetKit 沿表盘边缘 +排布。Widget 使用数值型进度,避免计时 ProgressView 布局产生非法坐标。 +下一节预览不显示当前课程的进度。 +无课、没有完整覆盖、过期、未同步及退出登录分别呈现。今日与本周统计分别 +要求完整覆盖,不展示部分同步数据的误导性总数。 + +## 通知职责 + +课程提醒由 iPhone 本地通知系统调度并由系统转发到 Apple Watch。Watch App +不重复创建相同提醒,避免手机和手表同时通知。 + +## 构建与验证 + +设备触觉、界面布局及配对传输效果已通过实体机操作验证,由项目维护者确认。 +2026-09-08 使用 Xcode 27.0(27A5209h)、watchOS/iOS Simulator 27.0 SDK 完成 +以下自动化验证;构建均设置 `CODE_SIGNING_ALLOWED=NO`。 + +| 检查 | 结果 | +| --- | --- | +| `TraintimeWatch`,包含 Watch Widget | 构建成功 | +| `Runner`,包含 iPhone 与 Watch 扩展 | 构建成功 | +| Swift 日程、缓存与排序回归 | 123 项通过 | +| Swift 交互、生命周期与缓存回归 | 93 项通过 | +| Flutter Watch 快照回归 | 8 项通过 | +| 本地化审计脚本回归 | 8 项通过 | +| 签名脚本回归 | 6 项通过 | +| String Catalog 检查 | 187 条可翻译文案,简体源文案及繁体、英语完整 | +| Git 差异格式检查 | 通过 | + +该次 iPhone 构建包含第三方插件的并发、弃用 API 和数值转换警告,以及 +`LaunchImage` 未分配资源警告,均未阻断本次构建。无签名构建检查编译、链接、 +资源与嵌入关系,与维护者确认的实体机操作验证分别记录。 + +复跑时在仓库根目录执行,并将项目兼容的 Flutter SDK 加入 `PATH`。 + +```bash +# 共享状态和缓存回归:直接编译生产 Swift 文件,使用临时 Defaults。 +bash tools/test_watch_regressions.sh + +# 字符串资源与格式参数。 +python3 tools/audit_watch_localizations.py + +# Flutter Watch 快照与 Python 回归。 +CI=true flutter test --no-pub test/watch_schedule_snapshot_test.dart +python3 -m unittest test/watch_localizations_audit_test.py +python3 -m unittest test/signing_scripts_test.py +git diff --check + +# Watch App 与 Widget;deployment target 保持 watchOS 10。 +xcodebuild -project ios/Runner.xcodeproj -scheme TraintimeWatch \ + -configuration Debug -destination 'generic/platform=watchOS Simulator' \ + -derivedDataPath /tmp/xdyou-watch-validation CODE_SIGNING_ALLOWED=NO build + +# iPhone 主工程,包含两个平台的小组件与 Watch App。 +xcodebuild -project ios/Runner.xcodeproj -scheme Runner \ + -configuration Debug -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath /tmp/xdyou-ios-validation CODE_SIGNING_ALLOWED=NO build +``` + +回归覆盖按钮取消与长按互斥、有效表冠与会话反转、完成去重、取消任务、触摸和 +表冠位移接续、月缓存淘汰、卡片持久化暂停、清空竞态、修订变化与跨日标记 +校验。界面实测重点是短概览、长课程列表、连续跨日/周/月、系统中断拖动、章节 +切换期间旧输入、旋转期间同步替换,以及在 watchOS 10 与 11 以上分别确认来源。 +组件指南的设备回归重点包括三种形态的图片与说明对应、持续轮播、欢迎页长按 +跳转、后台取消、第五页两秒退出,以及减少动态效果和 VoiceOver 下的手动阅读。 +长按渐强触觉和实际 Widget 布局的验证以实体机操作为依据,固定截图仅承担教学展示。 + +### 工程关联与签名检查 + +真机安装要求 iPhone、Watch App 和 Widget 的 Team、App Group 与 Companion +标识一致。共享容器标识同时用于 entitlements 和运行时存储入口,变更时需覆盖 +所有使用方。自动化构建的 `CODE_SIGNING_ALLOWED=NO` 不验证设备签名资格。 + +`tools/signing_for_upstream.py` 与 `tools/signing_for_local.py` 管理脚本中登记的 +两套配置。`--check` 仅校验配置,前者的 `--check --staged` 校验 Git 索引中的 +文件;不带检查参数时会改写受管配置。切换先校验整体一致性,写入失败时恢复 +已写文件。维护者使用前应核对脚本的配置映射,不将现有映射视为通用开发者配置。 + +## 开发约束 + +同步协议保持以下不变量: + +1. 同步更新 Dart、iPhone Swift、Watch Swift 与 schema; +2. 验证版本一致、版本变化、分页中途版本变化三条路径; +3. 保证当天和 14 天只替换自己的范围; +4. 只在完整学期安装后保存版本; +5. 不因失败、超时或坏分页清空旧缓存。 + +缓存实现保持以下不变量: + +1. 新缓存必须有 schema 或来源签名; +2. 可重建派生数据不要复制完整课程模型; +3. 动画和表冠逐帧路径不得编码 JSON 或写 UserDefaults; +4. App Group 键、语言和 schema 范围只在共享层定义; +5. 单项缓存损坏必须可以独立回退或重建。 + +界面交互保持以下不变量: + +1. 复用分页纯函数和表冠协调器,不复制停止计时逻辑; +2. 保持触摸、表冠和顶部箭头经过同一页面提交入口; +3. 检查 41mm、45mm 和 49mm 表径; +4. 验证周网格色块与空白区域的命中优先级; +5. 验证简体中文、繁体中文和英语; +6. 区分布局调整与交互逻辑验证,分别检查可见效果和状态边界。