diff --git a/.github/workflows/check_wearos.yaml b/.github/workflows/check_wearos.yaml
new file mode 100644
index 00000000..a9a3c404
--- /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:
+ test:
+ 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: Test and build native Wear OS app
+ working-directory: wearos/android
+ run: ./gradlew testDebugUnitTest assembleDebug
diff --git a/README.md b/README.md
index ebd71ae8..ecb595e5 100644
--- a/README.md
+++ b/README.md
@@ -36,6 +36,7 @@ XDYou,代码名称为 Traintime PDA,是为西电学生设计的开源信息
13. 上课前提醒。
14. 完备的国际化支持:支持繁体中文和英语。
15. 宿舍水机支持。
+16. 提供原生 Compose Wear OS 配套应用,可同步课程与付款码,并在断开手机时使用缓存数据。
## 其他特性
@@ -66,6 +67,23 @@ Engine • hash fcf463a2242790d1fdcd9d044f533080f5022e18 (revision 4c525dac5e) (
Tools • Dart 3.12.0 • DevTools 2.57.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 testDebugUnitTest assembleRelease
+```
+
## 授权信息
本程序源代码按照 MPLv2 授权,部分文件有 MIT / Apache-2.0 授权。
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 26e8def3..adb10dd2 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 a08f490c..9185431d 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)
}
+
+ override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
+ super.configureFlutterEngine(flutterEngine)
+ companionChannel = MethodChannel(
+ flutterEngine.dartExecutor.binaryMessenger,
+ WearCompanionTransport.CHANNEL,
+ ).also { channel ->
+ channel.setMethodCallHandler { call, result ->
+ when (call.method) {
+ "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
+ }
+ WearCompanionTransport.cachePayload(this, payload)
+ 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, payload.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 00000000..68f77561
--- /dev/null
+++ b/android/app/src/main/kotlin/io/github/benderblog/traintime_pda/WearCompanionTransport.kt
@@ -0,0 +1,70 @@
+package io.github.benderblog.traintime_pda
+
+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 cachePayload(context: Context, payload: String) {
+ context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ .edit().putString(PAYLOAD, payload).apply()
+ }
+
+ fun cachedPayload(context: Context): String? =
+ context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ .getString(PAYLOAD, 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/main.dart b/lib/main.dart
index fa09a30d..ce5521ee 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -27,6 +27,7 @@ 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/xidian_ids/ids_session.dart';
+import 'package:watermeter/repository/wear_companion_sync.dart';
import 'package:home_widget/home_widget.dart';
void main() async {
@@ -55,6 +56,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/homepage/refresh.dart b/lib/page/homepage/refresh.dart
index adf5495a..8918c807 100644
--- a/lib/page/homepage/refresh.dart
+++ b/lib/page/homepage/refresh.dart
@@ -4,6 +4,8 @@
// Refresh formula for homepage.
+import 'dart:io';
+
import 'package:watermeter/controller/classtable_controller.dart';
import 'package:watermeter/controller/energy_controller.dart';
import 'package:watermeter/controller/exam_controller.dart';
@@ -17,6 +19,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';
import 'package:watermeter/repository/xidian_ids/ids_session.dart';
import 'package:watermeter/repository/xidian_ids/ids_reauth_client.dart';
@@ -99,7 +102,16 @@ Future update({
}
// Sync login state to iOS widget
- final hasCredential = preference.getString(preference.Preference.idsAccount).isNotEmpty &&
+ final hasCredential =
+ 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) {
+ // Wear sync must never make the phone's normal refresh fail.
+ log.handle(e, s, '[homepage Update][WearCompanion] Cache failed');
+ }
+ }
}
diff --git a/lib/page/login/login_window.dart b/lib/page/login/login_window.dart
index fc084f99..0c8f650a 100644
--- a/lib/page/login/login_window.dart
+++ b/lib/page/login/login_window.dart
@@ -58,6 +58,7 @@ class _LoginWindowState extends State {
bool _couldNotView = true;
Widget contentColumn() => Column(
+ mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextField(
@@ -302,42 +303,63 @@ class _LoginWindowState extends State {
@override
Widget build(BuildContext context) {
+ final isLandscape = width / height > 1.0;
return Scaffold(
- body: Padding(
- padding: EdgeInsets.only(
- left: width / height > 1.0 ? width * 0.25 : widthOfSquare,
- right: width / height > 1.0 ? width * 0.25 : widthOfSquare,
- top: kToolbarHeight,
- ),
- child: width / height > 1.0
- ? Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- const AppIconWidget().gestures(
- onTap: () => Navigator.of(context).push(
- MaterialPageRoute(
- builder: (context) => const AboutPage(),
- ),
- ),
+ body: SafeArea(
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ final horizontalPadding = isLandscape
+ ? width * 0.25
+ : widthOfSquare;
+ const verticalPadding = 16.0;
+ return SingleChildScrollView(
+ keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
+ padding: EdgeInsets.symmetric(
+ horizontal: horizontalPadding,
+ vertical: verticalPadding,
+ ),
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ minHeight: max(
+ 0,
+ constraints.maxHeight - verticalPadding * 2,
),
- const SizedBox(width: 48),
- Expanded(child: contentColumn()),
- ],
- )
- : Column(
- children: [
- const AppIconWidget()
- .padding(vertical: kToolbarHeight * 0.75)
- .gestures(
- onTap: () => Navigator.of(context).push(
- MaterialPageRoute(
- builder: (context) => const AboutPage(),
+ ),
+ child: isLandscape
+ ? Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const AppIconWidget().gestures(
+ onTap: () => Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (context) => const AboutPage(),
+ ),
+ ),
),
- ),
+ const SizedBox(width: 48),
+ Expanded(child: contentColumn()),
+ ],
+ )
+ : Column(
+ mainAxisSize: MainAxisSize.min,
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const AppIconWidget()
+ .padding(vertical: kToolbarHeight * 0.75)
+ .gestures(
+ onTap: () => Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (context) => const AboutPage(),
+ ),
+ ),
+ ),
+ contentColumn(),
+ ],
),
- contentColumn(),
- ],
- ).center(),
+ ),
+ );
+ },
+ ),
),
);
}
diff --git a/lib/page/setting/setting.dart b/lib/page/setting/setting.dart
index 99685716..5d9382d1 100644
--- a/lib/page/setting/setting.dart
+++ b/lib/page/setting/setting.dart
@@ -23,6 +23,7 @@ import 'package:watermeter/page/public_widget/re_x_card.dart';
import 'package:watermeter/page/setting/dialogs/change_color_dialog.dart';
import 'package:watermeter/page/setting/dialogs/change_localization_dialog.dart';
import 'package:watermeter/page/setting/dialogs/aircon_imei_dialog.dart';
+import 'package:watermeter/page/setting/wear_companion_sync_page.dart';
import 'package:watermeter/page/setting/dialogs/schoolnet_password_dialog.dart';
import 'package:watermeter/page/setting/dialogs/semester_switch_dialog.dart';
import 'package:watermeter/page/setting/dialogs/update_dialog.dart';
@@ -198,6 +199,19 @@ class _SettingWindowState extends State {
remaining: const [],
bottomRow: Column(
children: [
+ if (Platform.isAndroid) ...[
+ ListTile(
+ title: const Text('XDYou Wear'),
+ subtitle: const Text('配对手表并同步登录状态、课表和实验安排'),
+ trailing: const Icon(Icons.watch_outlined),
+ onTap: () => Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => const WearCompanionSyncPage(),
+ ),
+ ),
+ ),
+ const Divider(),
+ ],
ListTile(
title: Text(
FlutterI18n.translate(
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 00000000..4f3e2af1
--- /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/xidian_ids/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 00000000..90f3625b
--- /dev/null
+++ b/lib/repository/wear_companion_sync.dart
@@ -0,0 +1,167 @@
+// 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/xidian_ids/classtable_session.dart';
+import 'package:watermeter/repository/xidian_ids/school_card_session.dart';
+import 'package:watermeter/repository/xidian_ids/ids_reauth_client.dart';
+import 'package:watermeter/repository/xidian_ids/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();
+
+ 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 = preference.getString(preference.Preference.idsAccount);
+ final password = preference.getString(preference.Preference.idsPassword);
+ final semester = preference.getString(
+ preference.Preference.currentSemester,
+ );
+ 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,
+ // The watch uses these credentials only for the payment-code exception.
+ 'credentials': {
+ 'idsAccount': account,
+ 'idsPassword': password,
+ if (preference.contains(preference.Preference.role))
+ 'isPostGraduate': preference.getBool(preference.Preference.role),
+ 'currentSemester': semester,
+ },
+ '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