Skip to content
Closed
39 changes: 36 additions & 3 deletions android/app/src/main/kotlin/dev/so1ve/bitree/TimetableWidget.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import android.text.TextPaint
import android.view.View
import android.widget.RemoteViews
import androidx.core.widget.RemoteViewsCompat
import kotlin.math.max
import kotlin.math.roundToInt

open class TimetableWidget : AppWidgetProvider() {
override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) {
Expand Down Expand Up @@ -79,6 +81,7 @@ open class TimetableWidget : AppWidgetProvider() {
views.setTextViewText(R.id.widget_empty, content.emptyMessage)
views.setEmptyView(R.id.widget_courses, R.id.widget_empty)
views.setOnClickPendingIntent(R.id.widget_root, open)
views.setOnClickPendingIntent(R.id.widget_empty, open)
// Collection views consume taps; forward them to the same app launch.
views.setPendingIntentTemplate(R.id.widget_courses, open)

Expand All @@ -94,7 +97,8 @@ open class TimetableWidget : AppWidgetProvider() {
}

val widthPx = widgetWidthPx(context, manager, id)
val items = entryItems(context, content.entries, widthPx, columns)
val heightPx = widgetContentMinHeightPx(context, manager, id)
val items = entryItems(context, content.entries, widthPx, heightPx, columns)

RemoteViewsCompat.setRemoteAdapter(
context, views, id, R.id.widget_courses, items,
Expand All @@ -121,10 +125,31 @@ open class TimetableWidget : AppWidgetProvider() {
}
}

private fun widgetContentMinHeightPx(
context: Context,
manager: AppWidgetManager,
id: Int,
): Float {
val resources = context.resources
val options = manager.getAppWidgetOptions(id)
val heightDp = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT)
val widgetHeight = if (heightDp > 0) {
heightDp * resources.displayMetrics.density
} else {
manager.getAppWidgetInfo(id)?.minHeight?.toFloat() ?: 0f
}
val chromeHeight =
2 * resources.getDimension(R.dimen.widget_vertical_padding) +
resources.getDimension(R.dimen.widget_header_min_height) +
resources.getDimension(R.dimen.widget_header_spacing)
return max(0f, widgetHeight - chromeHeight)
}

private fun entryItems(
context: Context,
entries: List<WidgetEntry>,
widthPx: Float,
heightPx: Float,
columns: Int,
): RemoteViewsCompat.RemoteCollectionItems {
val resources = context.resources
Expand All @@ -140,6 +165,10 @@ open class TimetableWidget : AppWidgetProvider() {

val items = RemoteViewsCompat.RemoteCollectionItems.Builder()
.setViewTypeCount(1)
if (entries.isEmpty()) return items.build()

val content = RemoteViews(context.packageName, R.layout.widget_content)
content.removeAllViews(R.id.widget_content_stack)
entries.chunked(columns).forEachIndexed { index, entriesInRow ->
val row = RemoteViews(context.packageName, R.layout.widget_row)
row.removeAllViews(R.id.widget_first_column)
Expand All @@ -159,9 +188,13 @@ open class TimetableWidget : AppWidgetProvider() {
entryView(context, entry, label),
)
}
row.setOnClickFillInIntent(R.id.widget_row, Intent())
items.addItem(index.toLong(), row)
content.addView(R.id.widget_content_stack, row)
}
with(RemoteViewsCompat) {
content.setViewMinimumHeight(R.id.widget_content_stack, heightPx.roundToInt())
}
content.setOnClickFillInIntent(R.id.widget_content_stack, Intent())
items.addItem(0L, content)

return items.build()
}
Expand Down
7 changes: 4 additions & 3 deletions android/app/src/main/res/layout/timetable_widget.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,23 @@
android:orientation="vertical"
android:paddingStart="@dimen/widget_padding"
android:paddingEnd="@dimen/widget_padding"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:paddingTop="@dimen/widget_vertical_padding"
android:paddingBottom="@dimen/widget_vertical_padding"
android:background="@drawable/widget_background"
android:clipToOutline="true">
<include layout="@layout/widget_header" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="6dp">
android:layout_marginTop="@dimen/widget_header_spacing">
<ListView
android:id="@+id/widget_courses"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:divider="@null"
android:dividerHeight="0dp"
android:listSelector="@android:color/transparent"
android:scrollbars="vertical"
android:scrollbarStyle="insideOverlay"
android:scrollbarSize="2dp"
Expand Down
6 changes: 6 additions & 0 deletions android/app/src/main/res/layout/widget_content.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_content_stack"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
2 changes: 1 addition & 1 deletion android/app/src/main/res/layout/widget_header.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:minHeight="24dp"
android:minHeight="@dimen/widget_header_min_height"
android:gravity="center_vertical">
<TextView
android:id="@+id/widget_date"
Expand Down
5 changes: 4 additions & 1 deletion android/app/src/main/res/values/dimens.xml
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="widget_background_radius">20dp</dimen>
<dimen name="widget_background_radius">14dp</dimen>
<dimen name="widget_course_radius">6dp</dimen>
<dimen name="widget_padding">10dp</dimen>
<dimen name="widget_vertical_padding">8dp</dimen>
<dimen name="widget_header_min_height">24dp</dimen>
<dimen name="widget_header_spacing">6dp</dimen>
<dimen name="widget_column_spacing">6dp</dimen>
<dimen name="widget_course_padding">8dp</dimen>
<dimen name="widget_course_details_size">11sp</dimen>
Expand Down
11 changes: 8 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,14 @@ flowchart LR

`SchoolController` 负责登录状态、同步操作、请求繁忙状态和错误反馈。成功获取课表、DDL、考试或成绩后,交给相应功能控制器合并并保存。

Android 小组件由 `TimetableWidget` 更新、由 `WidgetAgenda` 读取本地课表与日程。
整个组件共享一个打开应用的 `PendingIntent`:根节点处理标题与边缘,集合模板处理
课程、列表留白和空状态。课程行合并为单个可滚动集合项,并以列表可视高度作为最小
高度,避免 `ListView` 吞掉空白区域点击,同时保留纵向滚动。

`sync/SyncController` 只持有一轮同步的所选内容、进行状态和结果,不提供任务队列、逐项追加或任务历史。覆盖课表、考试安排、成绩、学分进度及乐学、新延河课堂、MOOC、大物实验 DDL。共享学校会话的读取顺序执行,MOOC 独立并行;开始前等待其他学校查询结束,取消等待不会中断那次查询。大物实验 DDL 在本轮课表读取结束后生成。日程导入在 `AgendaController` 内顺序合并,基于最新已保存数据构造结果,并在真正写入前再次检查请求是否已取消,避免并行平台互相覆盖或取消后写入等待中的结果。

首页和日程页共用 `SyncButton`,菜单只有「同步全部」「自定义同步」和「已删除的 DDL」,同步操作与删除恢复操作之间有一条分隔线。「同步全部」默认覆盖可用来源,跳过尚未登录的 MOOC;自定义抽屉按课表与学业、DDL 两组勾选,主动勾选 MOOC 时可登录。同步静默开始,按钮显示加载状态,再次点击才展开「同步进度」。该抽屉只显示本轮进度、各项结果、整轮取消和重试未完成内容;重试保留本轮指定的学期,返回、收起和下滑关闭不会取消。完成或失败统一反馈一次,抽屉已展开时直接更新列表,不叠加通知遮住操作。
首页和日程页共用 `SyncButton`,菜单只包含「同步全部」和「自定义同步」,不承载本地数据维护。成绩和学分进度只在各自页面提供同步入口;已删除的 DDL 只在日程页头提供恢复入口,与删除后的原位撤销共同组成恢复流程。「同步全部」默认覆盖可用的课表、考试和 DDL 来源,跳过尚未登录的 MOOC;自定义抽屉按课表与 DDL 两组勾选,主动勾选 MOOC 时可登录。同步静默开始,按钮显示加载状态,再次点击才展开「同步进度」。该抽屉只显示本轮进度、各项结果、整轮取消和重试未完成内容;重试保留本轮指定的学期,返回、收起和下滑关闭不会取消。完成或失败统一反馈一次,抽屉已展开时直接更新列表,不叠加通知遮住操作。

启动时只恢复学校登录,不同步业务数据。恢复登录和后台同步不提供交互验证回调;遇到短信或图形验证时,在获取手机号、验证码图片或发送短信前停止相关读取,并保留「需要短信验证/需要图形验证」账号状态,独立平台继续同步。用户主动重试未完成的内容或查询时可完成验证码验证;取消验证则保留同一待验证状态。

Expand All @@ -43,7 +48,7 @@ flowchart LR

USTC 风控采集失败时遵循学校登录页的协议:仅在风控请求出现 HTTP/连接错误,或响应不是有效 token 时,将加密的 `risk_payload` 设为 `{"error":true}`,仍保留 `riskEngine=true` 并由学校继续认证。这不是登录成功状态;密码错误、短信验证、服务回调和登录会话检查照常执行。取消、凭据存储失败和程序错误不进入此处理。采集失败的技术原因只写入日志,不单独弹出内部接口地址。`/gate` 是公开页面配置的前端资源网关,不能直接据此改写独立风控服务地址。

数据仍使用 `schedule.json`、`agenda.json`、`academics.json` 和原有 JSON 字段;凭据与 Cookie 仍使用原有系统安全存储。DDL 同步保留本地编辑、删除标记和已完成状态,并接收平台完成状态。恢复已删除 DDL 是独立操作。
数据仍使用 `schedule.json`、`agenda.json`、`academics.json` 和原有 JSON 字段;凭据与 Cookie 仍使用原有系统安全存储。DDL 同步保留本地编辑、删除标记和已完成状态,并接收平台完成状态。删除 DDL 时先提供原位撤销;离开当前视图后,可从日程页头的「已删除的 DDL」批量恢复。恢复不会出现在同步菜单中。

四个 DDL 来源统一在自定义同步中选择,不再占据顶层菜单的四个入口。`SchoolController.importDeadlines` 的可选 `source` 用于所选学校平台,省略时依次处理乐学与新延河课堂,保留逐平台错误处理、取消和登录状态修复。MOOC 使用独立的账号和控制器,不进入学校认证流程。大物实验为本地操作。

Expand Down Expand Up @@ -94,7 +99,7 @@ DDL 绘制与布局分离:右端为 `dueAt`,左侧渐隐,屏幕宽度为
## 手势与布局

- `SwipePager` 统一鼠标与触摸分页,拖过半页即报告新页,程序切页不报告中间页。
- `SwipeActionTile` 以明确的横向意图接管单条操作,不增加按住等待。短滑露出按钮,长滑达到阈值后松手执行;左滑完成或恢复 DDL,右滑删除。快速收回和反向拖回都可以取消。`TapRegion` 处理外部触摸收起,最近的纵向 `ScrollPosition` 处理滚动收起,不额外维护全局打开条目。滑出、按钮停靠与回弹共用动画,保存失败回到原位。行的增删尺寸动画由 `AnimatedSliverChildren` 统一处理。
- `SwipeActionTile` 以明确的横向意图接管单条操作,不增加按住等待。短滑露出按钮,长滑达到阈值后松手执行;左滑删除,右滑完成或恢复 DDL。快速收回和反向拖回都可以取消。`TapRegion` 处理外部触摸收起,最近的纵向 `ScrollPosition` 处理滚动收起,不额外维护全局打开条目。滑出、按钮停靠与回弹共用动画,保存失败回到原位。行的增删尺寸动画由 `AnimatedSliverChildren` 统一处理。
- `PullToRevealList` 管理下拉手势和展开状态,`RevealScrollController` 在布局阶段同步内容高度与滚动位置。用户重新拖动时解除滚动保持,直接接管滚动。
- `AppMenuAnchor` 使用原生 `RawMenuAnchor` 管理焦点与菜单生命周期,统一面板的开合动效。菜单高度取触发器上下的实际可用空间。
- `SheetLayout`、`EditorSheet` 和主题共同约束弹层尺寸、固定操作区与可滚动内容。地图的缩放、绘制和命中测试由 `WencuiFloorMap` 管理。
Expand Down
11 changes: 10 additions & 1 deletion docs/ui-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
| 按钮、表单、下拉触发器、菜单、分段选择、标签、导航选中背景 | 10px 圆角 |
| 对话框、底部弹层 | 20px 圆角;底部弹层只圆顶部 |
| 课表紧凑块、小标记、提示标签 | 6px 圆角 |
| Android 桌面小组件外层 | 14dp 圆角 |
| 完成状态、地图定位点、时钟表盘、图表 | 保留表达其含义的形状 |

普通操作的最小触摸目标为 48px。按钮使用同一字号、字重和内边距;
Expand Down Expand Up @@ -61,6 +62,11 @@

新增控件优先复用原生组件的主题;只有确实共用布局或交互时才提取组件。

Android 桌面小组件整卡都是打开应用的点击区域,包括标题、课程卡、列表留白和
空状态,不为不同区域绑定不同去向。课程列表仍可上下滚动;所有课程行放在同一个
集合项中,该集合项的最小高度覆盖列表可视区,使下方留白也参与点击。外层背景与
内容共同裁切到 14dp 圆角。

## 语言与文案

语言入口位于“设置 → 语言”,提供跟随系统、简体中文和 English,切换后立即生效并保留选择。语言名称使用各自的自称,不在中文界面中把 English 译成英文,也不在英文界面中把简体中文译成 Chinese。
Expand Down Expand Up @@ -90,9 +96,12 @@
点击课程打开课程 drawer,展示下一门课和换楼关系;点击下一门课在同一 drawer
内查看。点击日期打开当日日程 drawer,复用日程列表的完成与滑动操作,已完成项折叠。
课程不计入日程数量,也不跳转到日程页面。
课程冲突块使用中性承托面,顶部显示冲突数量;每门课程以自身配色形成连续色带,
直接显示课程名、已有建筑或教室与精确节次;地点为空时不显示占位文案,也不再叠加
重复的悬浮详情或强调色侧边条。

日程列表以顶部的“未完成/已完成”分段选择切换内容,不同时使用横滑切页。
条目横拖立即跟手:短滑露出可点击的操作按钮,长滑达到阈值后振动并显示松手提示。
左滑完成或恢复 DDL,右滑删除;松手前可以拖回取消。展开后再次拖动从当前位置继续,
左滑删除,右滑完成或恢复 DDL;松手前可以拖回取消。展开后再次拖动从当前位置继续,
点击条目先收起,点击外部、滚动或开始操作另一条也会收起。纵向和斜向拖动优先交给滚动,
触控板手势不执行条目操作。
23 changes: 21 additions & 2 deletions lib/academics/ui/credit_targets_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class _CreditTargetsDialogState extends State<_CreditTargetsDialog> {
};
final _newCategory = TextEditingController();
bool _saving = false;
String? _error;

@override
void dispose() {
Expand All @@ -62,7 +63,11 @@ class _CreditTargetsDialogState extends State<_CreditTargetsDialog> {

Future<void> _save() async {
if (!_form.currentState!.validate()) return;
setState(() => _saving = true);
setState(() {
_saving = true;
_error = null;
});
final error = context.l10n.operationSaveFailed;
final record = widget.controller.record;
final saved = await widget.controller.save(
record.withCreditTargets(
Expand All @@ -78,13 +83,17 @@ class _CreditTargetsDialogState extends State<_CreditTargetsDialog> {
if (saved) {
Navigator.pop(context);
} else {
setState(() => _saving = false);
setState(() {
_saving = false;
_error = error;
});
}
}

@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final theme = Theme.of(context);
return PopScope(
canPop: !_saving,
child: AlertDialog(
Expand Down Expand Up @@ -154,6 +163,16 @@ class _CreditTargetsDialogState extends State<_CreditTargetsDialog> {
),
],
),
if (_error != null) ...[
const SizedBox(height: AppSpacing.lg),
Semantics(
liveRegion: true,
child: Text(
_error!,
style: TextStyle(color: theme.colorScheme.error),
),
),
],
],
),
),
Expand Down
14 changes: 13 additions & 1 deletion lib/agenda/ui/agenda_detail.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import '../../app/controller.dart';
import '../../l10n/formats.dart';
import '../../l10n/l10n.dart';
import '../../schedule/model.dart';
import '../../ui/app_notice.dart';
import '../../ui/external_link.dart';
import '../../ui/sheet_layout.dart';
import '../model.dart';
Expand Down Expand Up @@ -204,7 +205,18 @@ Future<void> showAgendaEntry(
: null,
onPressed: controller.agenda.busy
? null
: () => toggleAgendaDone(controller.agenda, current),
: () async {
final saved = await toggleAgendaDone(
controller.agenda,
current,
);
if (!saved && context.mounted) {
showAppNotice(
context,
context.l10n.operationSaveFailed,
);
}
},
icon: Icon(
current.done ? Icons.undo_rounded : Icons.check_rounded,
size: 20,
Expand Down
8 changes: 7 additions & 1 deletion lib/agenda/ui/agenda_editor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ class _AgendaEditorState extends State<_AgendaEditor> {
_saving = true;
_error = null;
});
final l10n = context.l10n;
final success = await widget.controller.agenda.save(
AgendaEntry(
id:
Expand All @@ -169,12 +170,14 @@ class _AgendaEditorState extends State<_AgendaEditor> {
} else {
setState(() {
_saving = false;
_error = l10n.operationSaveFailed;
});
}
}

Future<void> _delete() async {
setState(() => _saving = true);
final error = context.l10n.operationDeleteFailed;
final success = await deleteAgendaEntry(
context,
widget.controller,
Expand All @@ -184,7 +187,10 @@ class _AgendaEditorState extends State<_AgendaEditor> {
if (success) {
Navigator.pop(context);
} else {
setState(() => _saving = false);
setState(() {
_saving = false;
_error = error;
});
}
}

Expand Down
Loading
Loading