diff --git a/.github/workflows/check_wearos.yaml b/.github/workflows/check_wearos.yaml
new file mode 100644
index 000000000..1f5cac7f5
--- /dev/null
+++ b/.github/workflows/check_wearos.yaml
@@ -0,0 +1,40 @@
+name: Check Wear OS
+
+on:
+ pull_request:
+ paths:
+ - "wearos/**"
+ - "lib/page/setting/wear_companion_sync_page.dart"
+ - "lib/repository/wear_companion_sync.dart"
+ - "android/app/src/main/kotlin/io/github/benderblog/traintime_pda/MainActivity.kt"
+ - "android/app/src/main/kotlin/io/github/benderblog/traintime_pda/WearCompanionTransport.kt"
+ - ".github/workflows/check_wearos.yaml"
+ push:
+ branches:
+ - main
+ paths:
+ - "wearos/**"
+ - "lib/page/setting/wear_companion_sync_page.dart"
+ - "lib/repository/wear_companion_sync.dart"
+ - "android/app/src/main/kotlin/io/github/benderblog/traintime_pda/MainActivity.kt"
+ - "android/app/src/main/kotlin/io/github/benderblog/traintime_pda/WearCompanionTransport.kt"
+ - ".github/workflows/check_wearos.yaml"
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Set up Java
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: 17
+
+ - name: Build native Wear OS app
+ working-directory: wearos/android
+ run: ./gradlew assembleDebug
diff --git a/README.md b/README.md
index 1fcbdd01c..21fa7400d 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,7 @@ XDYou,代码名称为 Traintime PDA,是为西电学生设计的开源信息
13. 上课前提醒。
14. 完备的国际化支持:支持繁体中文和英语。
15. 宿舍水机支持。
+16. 提供原生 Compose Wear OS 配套应用,可同步课程与付款码,并在断开手机时使用缓存数据。
## 其他特性
@@ -64,6 +65,23 @@ Engine • hash 59d54a2b2896a6bbf356c94b7fac7b9e235bdacd (revision
Tools • Dart 3.13.0 • DevTools 2.60.0
```
+### 仓库结构与 Wear OS 构建
+
+主应用位于仓库根目录,原生 Kotlin + Compose for Wear OS 应用位于 [`wearos/android/`](./wearos/android)。两端通信协议需要同步演进,因此 Wear OS 源码直接维护在同一仓库中,不使用额外 submodule。
+
+主应用仍使用仓库内 Flutter SDK,首次拉取后需要初始化子模块:
+
+```bash
+git submodule update --init --recursive
+```
+
+构建 Wear OS 应用:
+
+```bash
+cd wearos/android
+./gradlew assembleRelease
+```
+
## 授权信息
本程序源代码按照 MPLv2 授权,部分文件有 MIT / Apache-2.0 授权。
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 52e75d29c..3bb2e06d6 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -129,6 +129,7 @@ dependencies {
implementation 'androidx.glance:glance-preview:1.1.1'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-core:1.9.0'
+ implementation 'com.google.android.gms:play-services-wearable:19.0.0'
}
ext.abiCodes = ["x86_64": 1, "armeabi-v7a": 2, "arm64-v8a": 3]
@@ -140,4 +141,4 @@ android.applicationVariants.all { variant ->
output.versionCodeOverride = variant.versionCode * 10 + abiVersionCode
}
}
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index a08f490cc..9185431db 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -9,6 +9,18 @@
android:networkSecurityConfig="@xml/network_security_config"
tools:replace="android:label">
+
+
+
+
+
+
+
()
+
+ private data class PendingSync(
+ val sessionId: String,
+ val result: MethodChannel.Result,
+ val timeout: Runnable,
+ )
+
override fun onCreate(savedInstanceState: Bundle?) {
// Enable edge-to-edge display
WindowCompat.enableEdgeToEdge(window)
super.onCreate(savedInstanceState)
+ WearCompanionTransport.cachedPayload(this)
+ }
+
+ override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
+ super.configureFlutterEngine(flutterEngine)
+ companionChannel = MethodChannel(
+ flutterEngine.dartExecutor.binaryMessenger,
+ WearCompanionTransport.CHANNEL,
+ ).also { channel ->
+ channel.setMethodCallHandler { call, result ->
+ when (call.method) {
+ "getCachedPaymentQr" -> {
+ val account = call.argument("idsAccount")
+ if (account.isNullOrBlank()) {
+ result.success(null)
+ } else {
+ val profileId = WearCompanionTransport.profileIdForAccount(this, account)
+ val snapshot = WearCompanionTransport.cachedPayload(this)?.let { JSONObject(it) }
+ val qr = if (snapshot?.optString("profileId") == profileId) snapshot.optJSONObject("paymentQr") else null
+ result.success(mapOf(
+ "profileId" to profileId,
+ "pngBase64" to qr?.optString("pngBase64"),
+ "fetchedAtEpochMs" to qr?.optLong("fetchedAtEpochMs", -1L),
+ ))
+ }
+ }
+ "getConnectedWearNodes" -> {
+ Wearable.getNodeClient(this).connectedNodes
+ .addOnSuccessListener { nodes ->
+ val pairedNodeId = WearCompanionTransport.pairedWatchNodeId(this)
+ result.success(nodes.map { node ->
+ mapOf(
+ "id" to node.id,
+ "name" to node.displayName,
+ "isNearby" to node.isNearby,
+ "isPaired" to (node.id == pairedNodeId),
+ )
+ })
+ }
+ .addOnFailureListener { error ->
+ result.error("nodes_unavailable", error.message, null)
+ }
+ }
+ "sendSyncPayload" -> {
+ val nodeId = call.argument("nodeId")
+ val path = call.argument("messagePath")
+ val payload = call.argument("payload")
+ if (nodeId.isNullOrBlank() ||
+ path != WearCompanionTransport.SYNC_PATH ||
+ payload.isNullOrBlank()
+ ) {
+ result.error("invalid_arguments", "Invalid Wear sync request", null)
+ return@setMethodCallHandler
+ }
+ val safePayload = WearCompanionTransport.sanitizedPayload(this, payload)
+ WearCompanionTransport.cachePayload(this, safePayload)
+ val sessionId = try {
+ JSONObject(payload).optString("sessionId")
+ } catch (_: Exception) {
+ ""
+ }
+ if (sessionId.isBlank()) {
+ result.error("invalid_arguments", "Sync session is missing", null)
+ return@setMethodCallHandler
+ }
+ pendingSyncResults.remove(nodeId)?.let { pending ->
+ mainHandler.removeCallbacks(pending.timeout)
+ pending.result.error(
+ "sync_replaced",
+ "A newer sync replaced this request",
+ null,
+ )
+ }
+ val timeout = Runnable {
+ val pending = pendingSyncResults.remove(nodeId)
+ pending?.result?.error(
+ "sync_not_confirmed",
+ "手表未确认数据导入,请保持手表配对页面开启后重试",
+ null,
+ )
+ }
+ pendingSyncResults[nodeId] = PendingSync(sessionId, result, timeout)
+ mainHandler.postDelayed(timeout, SYNC_ACK_TIMEOUT_MS)
+ Wearable.getMessageClient(this)
+ .sendMessage(nodeId, path, safePayload.toByteArray(Charsets.UTF_8))
+ .addOnFailureListener { error ->
+ mainHandler.post {
+ val pending = pendingSyncResults.remove(nodeId)
+ if (pending != null) {
+ mainHandler.removeCallbacks(pending.timeout)
+ pending.result.error("send_failed", error.message, null)
+ }
+ }
+ }
+ }
+ "cacheSyncPayload" -> {
+ val payload = call.argument("payload")
+ if (payload.isNullOrBlank()) {
+ result.error("invalid_arguments", "Sync payload is empty", null)
+ } else {
+ WearCompanionTransport.cachePayload(this, payload)
+ result.success(null)
+ }
+ }
+ "sendPaymentQrResponse" -> {
+ val nodeId = call.argument("nodeId")
+ val payload = call.argument("payload")
+ if (nodeId.isNullOrBlank() || payload.isNullOrBlank() ||
+ nodeId != WearCompanionTransport.pairedWatchNodeId(this)
+ ) {
+ result.error("invalid_arguments", "Invalid payment response", null)
+ } else {
+ Wearable.getMessageClient(this)
+ .sendMessage(
+ nodeId,
+ WearCompanionTransport.PAYMENT_RESPONSE_PATH,
+ payload.toByteArray(Charsets.UTF_8),
+ )
+ .addOnSuccessListener { result.success(null) }
+ .addOnFailureListener { error ->
+ result.error("send_failed", error.message, null)
+ }
+ }
+ }
+ else -> result.notImplemented()
+ }
+ }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ WearCompanionTransport.setPaymentProxyActive(true)
+ Wearable.getMessageClient(this).addListener(this)
+ }
+
+ override fun onPause() {
+ Wearable.getMessageClient(this).removeListener(this)
+ WearCompanionTransport.setPaymentProxyActive(false)
+ super.onPause()
+ }
+
+ override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
+ companionChannel?.setMethodCallHandler(null)
+ companionChannel = null
+ super.cleanUpFlutterEngine(flutterEngine)
+ }
+
+ override fun onMessageReceived(event: MessageEvent) {
+ if (event.path == WearCompanionTransport.SYNC_ACK_PATH) {
+ val sessionId = event.data.toString(Charsets.UTF_8)
+ mainHandler.post {
+ val pending = pendingSyncResults[event.sourceNodeId]
+ if (pending != null && pending.sessionId == sessionId) {
+ pendingSyncResults.remove(event.sourceNodeId)
+ mainHandler.removeCallbacks(pending.timeout)
+ WearCompanionTransport.rememberPairedWatch(this, event.sourceNodeId)
+ pending.result.success(null)
+ }
+ }
+ return
+ }
+ if (event.path != WearCompanionTransport.PAYMENT_REQUEST_PATH ||
+ event.sourceNodeId != WearCompanionTransport.pairedWatchNodeId(this)
+ ) return
+ runOnUiThread {
+ companionChannel?.invokeMethod("receivePaymentQrRequest", event.sourceNodeId)
+ }
+ }
+
+ companion object {
+ private const val SYNC_ACK_TIMEOUT_MS = 15_000L
}
}
diff --git a/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/WearCompanionTransport.kt b/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/WearCompanionTransport.kt
new file mode 100644
index 000000000..b34f0cfd6
--- /dev/null
+++ b/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/WearCompanionTransport.kt
@@ -0,0 +1,105 @@
+package io.github.benderblog.traintime_pda
+
+import org.json.JSONObject
+import java.util.UUID
+import android.content.Context
+import com.google.android.gms.wearable.MessageEvent
+import com.google.android.gms.wearable.Wearable
+import com.google.android.gms.wearable.WearableListenerService
+
+internal object WearCompanionTransport {
+ const val CHANNEL = "io.github.benderblog.traintime_pda/wear_companion_phone"
+ const val SYNC_PATH = "/traintime_pda_wear_os/sync/v1"
+ const val SYNC_ACK_PATH = "/traintime_pda_wear_os/sync/ack/v1"
+ const val REQUEST_PATH = "/traintime_pda_wear_os/request/v1"
+ const val PAYMENT_REQUEST_PATH = "/traintime_pda_wear_os/payment/request/v1"
+ const val PAYMENT_RESPONSE_PATH = "/traintime_pda_wear_os/payment/response/v1"
+ private const val PREFS = "wear_companion_transport"
+ private const val PAYLOAD = "latest_payload"
+ private const val PAIRED_WATCH_NODE_ID = "paired_watch_node_id"
+ @Volatile
+ private var paymentProxyActive = false
+
+ fun profileIdForAccount(context: Context, account: String): String {
+ val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ val previous = prefs.getString("profile_account", null)
+ val stored = prefs.getString("profile_id", null)
+ if (previous == account && stored != null) return stored
+ val id = UUID.randomUUID().toString()
+ prefs.edit().apply {
+ if (previous != null && previous != account) remove(PAYLOAD)
+ putString("profile_account", account)
+ putString("profile_id", id)
+ }.apply()
+ return id
+ }
+
+ fun sanitizedPayload(context: Context, payload: String): String {
+ val json = JSONObject(payload)
+ val legacyAccount = json.optJSONObject("credentials")?.optString("idsAccount")
+ if (!json.has("profileId") && !legacyAccount.isNullOrEmpty()) {
+ json.put("profileId", profileIdForAccount(context, legacyAccount))
+ }
+ json.remove("credentials")
+ return json.toString()
+ }
+
+ fun cachePayload(context: Context, payload: String) {
+ context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ .edit().putString(PAYLOAD, sanitizedPayload(context, payload)).apply()
+ }
+
+ fun cachedPayload(context: Context): String? {
+ val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ val stored = prefs.getString(PAYLOAD, null) ?: return null
+ return try {
+ sanitizedPayload(context, stored).also { safe ->
+ if (safe != stored) prefs.edit().putString(PAYLOAD, safe).apply()
+ }
+ } catch (_: Exception) {
+ prefs.edit().remove(PAYLOAD).apply()
+ null
+ }
+ }
+
+ fun rememberPairedWatch(context: Context, nodeId: String) {
+ context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ .edit().putString(PAIRED_WATCH_NODE_ID, nodeId).apply()
+ }
+
+ fun pairedWatchNodeId(context: Context): String? =
+ context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ .getString(PAIRED_WATCH_NODE_ID, null)
+
+ fun setPaymentProxyActive(active: Boolean) {
+ paymentProxyActive = active
+ }
+
+ fun isPaymentProxyActive(): Boolean = paymentProxyActive
+}
+
+/** Answers a paired watch with the last phone-generated snapshot. */
+class WearCompanionListenerService : WearableListenerService() {
+ override fun onMessageReceived(event: MessageEvent) {
+ if (event.sourceNodeId != WearCompanionTransport.pairedWatchNodeId(this)) return
+ when (event.path) {
+ WearCompanionTransport.REQUEST_PATH -> {
+ val payload = WearCompanionTransport.cachedPayload(this) ?: return
+ Wearable.getMessageClient(this).sendMessage(
+ event.sourceNodeId,
+ WearCompanionTransport.SYNC_PATH,
+ payload.toByteArray(Charsets.UTF_8),
+ )
+ }
+ WearCompanionTransport.PAYMENT_REQUEST_PATH -> {
+ if (WearCompanionTransport.isPaymentProxyActive()) return
+ Wearable.getMessageClient(this).sendMessage(
+ event.sourceNodeId,
+ WearCompanionTransport.PAYMENT_RESPONSE_PATH,
+ "{\"ok\":false,\"error\":\"phone_app_required\"}"
+ .toByteArray(Charsets.UTF_8),
+ )
+ }
+ }
+ }
+}
diff --git a/lib/controller/homepage_controller.dart b/lib/controller/homepage_controller.dart
index 6df0af445..368c080af 100644
--- a/lib/controller/homepage_controller.dart
+++ b/lib/controller/homepage_controller.dart
@@ -27,6 +27,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/wear_companion_sync.dart';
enum ArrangementState { fetching, fetched, error, none }
@@ -146,6 +147,13 @@ class HomepageController {
preference.getString(preference.Preference.idsAccount).isNotEmpty &&
preference.getString(preference.Preference.idsPassword).isNotEmpty;
await syncWidgetLoginState(hasCredential);
+ if (Platform.isAndroid && hasCredential) {
+ try {
+ await const WearCompanionSyncService().cacheLatestSnapshot();
+ } catch (e, s) {
+ log.handle(e, s, '[HomepageController][WearCompanion] Cache failed');
+ }
+ }
}
List _sortArrangements(Iterable data) {
diff --git a/lib/main.dart b/lib/main.dart
index aaeb6319c..eae5ea2e6 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -29,6 +29,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/wear_companion_sync.dart';
import 'package:watermeter/themes/font_setting.dart';
void main() async {
@@ -57,6 +58,7 @@ void main() async {
preference.prefs = await SharedPreferencesWithCache.create(
cacheOptions: const SharedPreferencesWithCacheOptions(),
);
+ await const WearCompanionSyncService().startPaymentProxy();
// Load package info.
preference.packageInfo = await PackageInfo.fromPlatform();
diff --git a/lib/page/setting/groups/core_section.dart b/lib/page/setting/groups/core_section.dart
index 8068686fe..bc68cf831 100644
--- a/lib/page/setting/groups/core_section.dart
+++ b/lib/page/setting/groups/core_section.dart
@@ -28,6 +28,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/page/setting/wear_companion_sync_page.dart';
class CoreSection extends StatelessWidget {
const CoreSection({super.key});
@@ -37,6 +38,14 @@ class CoreSection extends StatelessWidget {
return SectionSettingScaffold(
items: SettingSegmentedList(
items: [
+ if (Platform.isAndroid)
+ ListTile(
+ leading: const Icon(Icons.watch_outlined),
+ title: const Text('XDYou Wear'),
+ subtitle: const Text('配对 Wear OS 手表并同步数据'),
+ trailing: const Icon(Icons.navigate_next),
+ onTap: () => context.push(const WearCompanionSyncPage()),
+ ),
ListTile(
leading: const Icon(MingCuteIcons.mgc_document_line),
title: Text(context.t.setting.checkLogger),
diff --git a/lib/page/setting/wear_companion_sync_page.dart b/lib/page/setting/wear_companion_sync_page.dart
new file mode 100644
index 000000000..ceb414e4a
--- /dev/null
+++ b/lib/page/setting/wear_companion_sync_page.dart
@@ -0,0 +1,174 @@
+// Copyright 2026 Traintime PDA authors.
+// SPDX-License-Identifier: MPL-2.0
+
+import 'package:flutter/material.dart';
+import 'package:watermeter/page/login/ids_reauth_dialog.dart';
+import 'package:watermeter/repository/wear_companion_sync.dart';
+import 'package:watermeter/repository/ids_session/ids_reauth_client.dart';
+
+class WearCompanionSyncPage extends StatefulWidget {
+ const WearCompanionSyncPage({super.key});
+
+ @override
+ State createState() => _WearCompanionSyncPageState();
+}
+
+class _WearCompanionSyncPageState extends State {
+ final _service = const WearCompanionSyncService();
+ late Future> _nodesFuture = _service.connectedNodes();
+ String? _sendingNodeId;
+ String? _completedNodeId;
+ String? _status;
+
+ void _reload() {
+ setState(() {
+ _status = null;
+ _nodesFuture = _service.connectedNodes();
+ });
+ }
+
+ Future _pair(WearNode node) async {
+ if (_sendingNodeId != null) return;
+ setState(() {
+ _sendingNodeId = node.id;
+ _completedNodeId = null;
+ _status = '正在向 ${node.name} 同步…';
+ });
+ final previousReAuthHandler = activeIDSReAuthHandler;
+ Future pairingReAuthHandler(IDSReAuthClient client) async {
+ if (!mounted) throw const IDSReAuthRequiredException();
+ return showIDSReAuthDialog(context, client);
+ }
+
+ activeIDSReAuthHandler = pairingReAuthHandler;
+ try {
+ final paymentQrSynced = await _service.pairAndSync(node);
+ if (!mounted) return;
+ setState(() {
+ _sendingNodeId = null;
+ _completedNodeId = node.id;
+ _status = paymentQrSynced
+ ? '配对与缓存数据同步完成;付款码时间请查看手表'
+ : '配对与课表同步完成;付款码请在手表下拉刷新';
+ });
+ } catch (error) {
+ if (!mounted) return;
+ setState(() {
+ _sendingNodeId = null;
+ _status = error.toString();
+ });
+ } finally {
+ if (identical(activeIDSReAuthHandler, pairingReAuthHandler)) {
+ activeIDSReAuthHandler = previousReAuthHandler;
+ }
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('同步到 XDYou Wear'),
+ actions: [
+ IconButton(onPressed: _reload, icon: const Icon(Icons.refresh)),
+ ],
+ ),
+ body: FutureBuilder>(
+ future: _nodesFuture,
+ builder: (context, snapshot) {
+ if (snapshot.connectionState != ConnectionState.done) {
+ return const Center(child: CircularProgressIndicator());
+ }
+ if (snapshot.hasError) {
+ return _MessageView(
+ icon: Icons.watch_off_outlined,
+ message: '无法查找手表:${snapshot.error}',
+ onRetry: _reload,
+ );
+ }
+ final nodes = snapshot.data ?? const [];
+ if (nodes.isEmpty) {
+ return _MessageView(
+ icon: Icons.watch_off_outlined,
+ message: '未找到已连接的 Wear OS 手表\n请先在系统中连接手表,并打开手表端的配对页面',
+ onRetry: _reload,
+ );
+ }
+ return ListView(
+ padding: const EdgeInsets.all(16),
+ children: [
+ const Text('请先在手表端打开“配对手机”,然后选择设备:'),
+ const SizedBox(height: 12),
+ for (final node in nodes)
+ Card(
+ child: ListTile(
+ leading: const Icon(Icons.watch_outlined),
+ title: Text(node.name),
+ subtitle: Text(node.isNearby ? '附近设备' : '已连接设备'),
+ trailing: _sendingNodeId == node.id
+ ? const SizedBox.square(
+ dimension: 22,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : node.isPaired || _completedNodeId == node.id
+ ? OutlinedButton.icon(
+ onPressed: _sendingNodeId == null
+ ? () => _pair(node)
+ : null,
+ icon: const Icon(Icons.sync),
+ label: const Text('同步'),
+ )
+ : FilledButton(
+ onPressed: _sendingNodeId == null
+ ? () => _pair(node)
+ : null,
+ child: const Text('配对'),
+ ),
+ ),
+ ),
+ if (_status != null) ...[
+ const SizedBox(height: 12),
+ Text(_status!, textAlign: TextAlign.center),
+ ],
+ ],
+ );
+ },
+ ),
+ );
+ }
+}
+
+class _MessageView extends StatelessWidget {
+ final IconData icon;
+ final String message;
+ final VoidCallback onRetry;
+
+ const _MessageView({
+ required this.icon,
+ required this.message,
+ required this.onRetry,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(icon, size: 52),
+ const SizedBox(height: 12),
+ Text(message, textAlign: TextAlign.center),
+ const SizedBox(height: 16),
+ FilledButton.icon(
+ onPressed: onRetry,
+ icon: const Icon(Icons.refresh),
+ label: const Text('重新查找'),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/repository/wear_companion_sync.dart b/lib/repository/wear_companion_sync.dart
new file mode 100644
index 000000000..b33676840
--- /dev/null
+++ b/lib/repository/wear_companion_sync.dart
@@ -0,0 +1,175 @@
+// Copyright 2026 Traintime PDA authors.
+// SPDX-License-Identifier: MPL-2.0
+
+import 'dart:convert';
+
+import 'package:flutter/services.dart';
+import 'package:watermeter/repository/preference.dart' as preference;
+import 'package:watermeter/repository/ids_session/classtable_session.dart';
+import 'package:watermeter/repository/ids_session/school_card_session.dart';
+import 'package:watermeter/repository/ids_session/ids_reauth_client.dart';
+import 'package:watermeter/repository/ids_session/sysj_session.dart';
+
+/// Phone-side endpoint of the XDYou Wear companion protocol.
+class WearCompanionSyncService {
+ static const channelName =
+ 'io.github.benderblog.traintime_pda/wear_companion_phone';
+ static const syncPath = '/traintime_pda_wear_os/sync/v1';
+ static const _channel = MethodChannel(channelName);
+
+ const WearCompanionSyncService();
+
+ String get phoneAccount =>
+ preference.getString(preference.Preference.idsAccount);
+
+ Future startPaymentProxy() async {
+ _channel.setMethodCallHandler((call) async {
+ if (call.method != 'receivePaymentQrRequest') {
+ throw MissingPluginException('Unknown companion call ${call.method}');
+ }
+ final nodeId = call.arguments;
+ if (nodeId is! String || nodeId.isEmpty) return;
+ Map response;
+ try {
+ final bytes = await SchoolCardSession().getQRCode();
+ response = {
+ 'ok': true,
+ 'pngBase64': base64Encode(bytes),
+ 'fetchedAtEpochMs': DateTime.now().millisecondsSinceEpoch,
+ };
+ } on IDSReAuthRequiredException {
+ response = {'ok': false, 'error': 'phone_authentication_required'};
+ } on IDSReAuthCancelledException {
+ response = {'ok': false, 'error': 'phone_authentication_cancelled'};
+ } catch (_) {
+ response = {'ok': false, 'error': 'phone_payment_request_failed'};
+ }
+ await _channel.invokeMethod('sendPaymentQrResponse', {
+ 'nodeId': nodeId,
+ 'payload': jsonEncode(response),
+ });
+ });
+ }
+
+ Map buildSnapshot({
+ required String sessionId,
+ Uint8List? paymentQr,
+ DateTime? paymentQrFetchedAt,
+ }) {
+ final account = phoneAccount;
+ final password = preference.getString(preference.Preference.idsPassword);
+ final classTable = ClassTableSession().getCache()?.$2;
+ if (account.isEmpty || password.isEmpty) {
+ throw StateError('请先在手机端登录 IDS');
+ }
+ if (classTable == null) {
+ throw StateError('手机端暂无课表缓存,请先刷新首页');
+ }
+
+ final experiments = SysjSession().getCache()?.$2;
+ return {
+ 'schemaVersion': 1,
+ 'sessionId': sessionId,
+ 'schedule': {
+ 'classTable': classTable.toJson(),
+ if (experiments != null)
+ 'otherExperiments': experiments.map((item) => item.toJson()).toList(),
+ },
+ if (paymentQr != null)
+ 'paymentQr': {
+ 'pngBase64': base64Encode(paymentQr),
+ 'fetchedAtEpochMs':
+ (paymentQrFetchedAt ?? DateTime.now()).millisecondsSinceEpoch,
+ },
+ 'generatedAtEpochMs': DateTime.now().millisecondsSinceEpoch,
+ };
+ }
+
+ Future