Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a05b9ec
feat: add Apple Watch schedule companion
Jul 29, 2026
5ea3842
docs: explain Apple Watch architecture
Jul 29, 2026
fd6c91b
feat: 代码优化审计
Jul 30, 2026
5a992aa
优化同时进行国际化适配
Jul 30, 2026
decd4a5
国际化适配自动同步
Jul 30, 2026
9de0ee0
国际化bug修复
Jul 30, 2026
26f7f86
补充说明文档
Jul 30, 2026
83c239f
新增表冠操作,手势,加入动画,优化课表同步逻辑
Jul 31, 2026
7376cae
变更表冠操作逻辑,加入页切换横向动画,优化动画流畅度
Jul 31, 2026
42d1bd1
改名
Jul 31, 2026
1ec1bd9
feat: complete Apple Watch schedule companion
Aug 1, 2026
dbb610f
feat: refine Apple Watch experience and onboarding
Aug 2, 2026
262d6b7
perf(watch): smooth day view crown paging
Aug 3, 2026
57f9c86
feat(watch): expand complication and widget support
Aug 13, 2026
239d530
fix(watch): improve onboarding scroll feedback
Aug 14, 2026
b98a3ce
Merge branch 'main' into codex/apple-watch
qingye0312-cpu Aug 14, 2026
8a110d6
Merge upstream main into Apple Watch PR branch
Sep 5, 2026
db115fc
fix: unify Watch widget states and harden schedule synchronization
Sep 5, 2026
9ad3a9b
fix: harden Watch interaction lifecycle and derived caches
Sep 5, 2026
556e75b
fix: preserve upstream widget diagnostic logs
Sep 5, 2026
5ceb031
Merge branch 'BenderBlog:main' into codex/apple-watch
qingye0312-cpu Sep 7, 2026
937f32d
feat(watch): refine complications and overview
Sep 7, 2026
6a3c4d8
refactor(watch): consolidate schedule sync and localization
Sep 7, 2026
cc938f2
Merge remote-tracking branch 'origin/codex/apple-watch' into codex/ap…
Sep 7, 2026
ddf2241
新增 Apple Watch 课表概览页、表盘小组件与使用引导
Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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_*
2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
59 changes: 23 additions & 36 deletions ios/ClasstableWidget/ClasstableWidget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Date> = []
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<Date> = [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)
}
}
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion ios/ClasstableWidget/EventItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import Foundation
import SwiftUI
import WidgetKit

private let formatHourMinute = "HH:mm"
private let myDateFormatter = DateFormatter()
Expand Down Expand Up @@ -86,4 +87,3 @@ struct EventItem_Previews: PreviewProvider {
}
}
*/

514 changes: 505 additions & 9 deletions ios/Runner.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
Expand Down Expand Up @@ -91,6 +91,9 @@
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<MetalAPIValidationSettings
isEnabled = "No">
</MetalAPIValidationSettings>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2700"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A17A00000000000000000001"
BuildableName = "TraintimeWatch.app"
BlueprintName = "TraintimeWatch"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A17A00000000000000000001"
BuildableName = "TraintimeWatch.app"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A17A00000000000000000001"
BuildableName = "TraintimeWatch.app"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
63 changes: 54 additions & 9 deletions ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
}
66 changes: 66 additions & 0 deletions ios/Runner/PhoneWatchQueuedScheduleTransport.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading