diff --git a/android/app/src/main/kotlin/dev/so1ve/bitree/BuildingColors.kt b/android/app/src/main/kotlin/dev/so1ve/bitree/BuildingColors.kt index d9a125d..810c5c2 100644 --- a/android/app/src/main/kotlin/dev/so1ve/bitree/BuildingColors.kt +++ b/android/app/src/main/kotlin/dev/so1ve/bitree/BuildingColors.kt @@ -2,19 +2,64 @@ package dev.so1ve.bitree import android.content.Context import android.content.res.Configuration +import android.util.Log import org.json.JSONArray import org.json.JSONObject internal object BuildingColors { private data class Palette(val light: IntArray, val dark: IntArray) - private data class Rule(val name: String, val pattern: Regex, val palette: Palette) + private data class Rule( + val name: String, + val area: String?, + val pattern: Regex, + val palette: Palette, + ) private data class Match(val start: Int, val end: Int, val rule: Rule) private data class Table(val neutral: Palette, val rules: List) private var table: Table? = null + private val customColors = mutableMapOf>() + private const val PREFERENCES = "widget_appearance" + private fun colorsKey(dark: Boolean) = if (dark) "darkCourseColors" else "lightCourseColors" private val otherCampuses = Regex("中关村|西山|珠海|雄安|怀来") private val spacing = Regex("[\\s\\u00A0\\u3000·._\\-—–()()\\[\\]【】]") + fun savePalette(context: Context, dark: Boolean, colors: Map>) { + context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit() + .putString(colorsKey(dark), JSONObject(colors).toString()).apply() + customColors[dark] = colors.mapValues { (_, values) -> + values.map { it.toInt() }.toIntArray() + } + } + + private fun readPalette(context: Context, dark: Boolean): Map { + return customColors[dark] ?: run { + try { + val saved = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + .getString(colorsKey(dark), "{}") ?: "{}" + val json = JSONObject(saved) + json.keys().asSequence().associateWith { area -> + val values = json.getJSONArray(area) + require(values.length() == 2) + IntArray(2) { values.getLong(it).toInt() } + } + } catch (error: Exception) { + Log.w("BITree", "无法读取桌面组件的课程配色", error) + emptyMap() + }.also { customColors[dark] = it } + } + } + + fun widgetBackground(context: Context): Int { + val fallback = context.getColor(R.color.widget_surface) + if (!isDark(context)) return fallback + return readPalette(context, true)["surface"]?.firstOrNull() ?: fallback + } + + private fun isDark(context: Context): Boolean = + context.resources.configuration.uiMode and + Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES + fun forCourse(context: Context, room: String, campus: String): IntArray { val data = table ?: read(context).also { table = it } val normalized = room.map { char -> @@ -33,10 +78,12 @@ internal object BuildingColors { other.end - other.start > match.end - match.start } }.map { it.rule }.distinctBy { it.name } - val palette = buildings.singleOrNull()?.palette ?: data.neutral - val dark = context.resources.configuration.uiMode and - Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES - return if (dark) palette.dark else palette.light + val building = buildings.singleOrNull() + val palette = building?.palette ?: data.neutral + WidgetColors.course(context, building?.area)?.let { return it } + val dark = isDark(context) + return readPalette(context, dark)[building?.area ?: "neutral"] + ?: if (dark) palette.dark else palette.light } private fun read(context: Context): Table { @@ -52,7 +99,12 @@ internal object BuildingColors { palette(json.getJSONObject("neutral")), (0 until rows.length()).map { index -> val row = rows.getJSONObject(index) - Rule(row.getString("name"), Regex(row.getString("pattern")), palette(row)) + Rule( + row.getString("name"), + row.optString("area").takeIf { it.isNotEmpty() }, + Regex(row.getString("pattern")), + palette(row), + ) }, ) } diff --git a/android/app/src/main/kotlin/dev/so1ve/bitree/MainActivity.kt b/android/app/src/main/kotlin/dev/so1ve/bitree/MainActivity.kt index 2d224c7..9a88fc8 100644 --- a/android/app/src/main/kotlin/dev/so1ve/bitree/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/so1ve/bitree/MainActivity.kt @@ -1,15 +1,50 @@ package dev.so1ve.bitree +import android.content.res.Configuration import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel class MainActivity : FlutterActivity() { + private var themeChannel: MethodChannel? = null + + override fun onResume() { + super.onResume() + // Wallpaper colours may have changed while the app was in the background. + themeChannel?.invokeMethod("changed", null) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + TimetableWidget.updateAll(this) + themeChannel?.invokeMethod("changed", null) + } + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) + themeChannel = MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, "dev.so1ve.bitree/theme", + ).also { channel -> + channel.setMethodCallHandler { call, result -> + if (call.method == "getSystemPalette") { + result.success(SystemPalette.read(this)) + } else { + result.notImplemented() + } + } + } MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "dev.so1ve.bitree/widget") .setMethodCallHandler { call, result -> if (call.method == "update") { + call.argument>>("lightCourseColors")?.let { + BuildingColors.savePalette(this, false, it) + } + call.argument>>("darkCourseColors")?.let { + BuildingColors.savePalette(this, true, it) + } + call.argument("useSystemPalette")?.let { + WidgetColors.saveUseSystemPalette(this, it) + } TimetableWidget.updateAll(this) result.success(null) } else { @@ -17,4 +52,10 @@ class MainActivity : FlutterActivity() { } } } + + override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) { + themeChannel?.setMethodCallHandler(null) + themeChannel = null + super.cleanUpFlutterEngine(flutterEngine) + } } diff --git a/android/app/src/main/kotlin/dev/so1ve/bitree/SystemPalette.kt b/android/app/src/main/kotlin/dev/so1ve/bitree/SystemPalette.kt new file mode 100644 index 0000000..59d08cf --- /dev/null +++ b/android/app/src/main/kotlin/dev/so1ve/bitree/SystemPalette.kt @@ -0,0 +1,49 @@ +package dev.so1ve.bitree + +import android.content.Context +import android.os.Build + +/** Export Android 12+ tonal resources; older devices retain the app palette. */ +internal object SystemPalette { + fun read(context: Context): Map>? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return null + val families = mapOf( + "accent1" to intArrayOf( + android.R.color.system_accent1_0, android.R.color.system_accent1_10, android.R.color.system_accent1_50, + android.R.color.system_accent1_100, android.R.color.system_accent1_200, android.R.color.system_accent1_300, + android.R.color.system_accent1_400, android.R.color.system_accent1_500, android.R.color.system_accent1_600, + android.R.color.system_accent1_700, android.R.color.system_accent1_800, android.R.color.system_accent1_900, + android.R.color.system_accent1_1000, + ), + "accent2" to intArrayOf( + android.R.color.system_accent2_0, android.R.color.system_accent2_10, android.R.color.system_accent2_50, + android.R.color.system_accent2_100, android.R.color.system_accent2_200, android.R.color.system_accent2_300, + android.R.color.system_accent2_400, android.R.color.system_accent2_500, android.R.color.system_accent2_600, + android.R.color.system_accent2_700, android.R.color.system_accent2_800, android.R.color.system_accent2_900, + android.R.color.system_accent2_1000, + ), + "accent3" to intArrayOf( + android.R.color.system_accent3_0, android.R.color.system_accent3_10, android.R.color.system_accent3_50, + android.R.color.system_accent3_100, android.R.color.system_accent3_200, android.R.color.system_accent3_300, + android.R.color.system_accent3_400, android.R.color.system_accent3_500, android.R.color.system_accent3_600, + android.R.color.system_accent3_700, android.R.color.system_accent3_800, android.R.color.system_accent3_900, + android.R.color.system_accent3_1000, + ), + "neutral1" to intArrayOf( + android.R.color.system_neutral1_0, android.R.color.system_neutral1_10, android.R.color.system_neutral1_50, + android.R.color.system_neutral1_100, android.R.color.system_neutral1_200, android.R.color.system_neutral1_300, + android.R.color.system_neutral1_400, android.R.color.system_neutral1_500, android.R.color.system_neutral1_600, + android.R.color.system_neutral1_700, android.R.color.system_neutral1_800, android.R.color.system_neutral1_900, + android.R.color.system_neutral1_1000, + ), + "neutral2" to intArrayOf( + android.R.color.system_neutral2_0, android.R.color.system_neutral2_10, android.R.color.system_neutral2_50, + android.R.color.system_neutral2_100, android.R.color.system_neutral2_200, android.R.color.system_neutral2_300, + android.R.color.system_neutral2_400, android.R.color.system_neutral2_500, android.R.color.system_neutral2_600, + android.R.color.system_neutral2_700, android.R.color.system_neutral2_800, android.R.color.system_neutral2_900, + android.R.color.system_neutral2_1000, + ), + ) + return families.mapValues { (_, colors) -> colors.map { context.getColor(it) } } + } +} diff --git a/android/app/src/main/kotlin/dev/so1ve/bitree/TimetableWidget.kt b/android/app/src/main/kotlin/dev/so1ve/bitree/TimetableWidget.kt index 345f6e2..154c3a7 100644 --- a/android/app/src/main/kotlin/dev/so1ve/bitree/TimetableWidget.kt +++ b/android/app/src/main/kotlin/dev/so1ve/bitree/TimetableWidget.kt @@ -75,6 +75,12 @@ open class TimetableWidget : AppWidgetProvider() { ) widgets.forEach { (id, columns) -> val views = RemoteViews(context.packageName, R.layout.timetable_widget) + views.setInt( + R.id.widget_content, "setBackgroundColor", WidgetColors.background(context), + ) + views.setTextColor(R.id.widget_date, WidgetColors.text(context)) + views.setTextColor(R.id.widget_empty, WidgetColors.muted(context)) + views.setTextColor(R.id.widget_message, WidgetColors.accent(context)) views.setTextViewText(R.id.widget_date, content.heading) views.setTextViewText(R.id.widget_empty, content.emptyMessage) views.setEmptyView(R.id.widget_courses, R.id.widget_empty) diff --git a/android/app/src/main/kotlin/dev/so1ve/bitree/WidgetColors.kt b/android/app/src/main/kotlin/dev/so1ve/bitree/WidgetColors.kt new file mode 100644 index 0000000..5028752 --- /dev/null +++ b/android/app/src/main/kotlin/dev/so1ve/bitree/WidgetColors.kt @@ -0,0 +1,91 @@ +package dev.so1ve.bitree + +import android.content.Context +import android.content.res.Configuration +import android.os.Build + +/** Widget surfaces follow Android; courses opt into the system palette separately. */ +internal object WidgetColors { + private const val PREFERENCES = "widget_appearance" + private const val USE_SYSTEM_PALETTE = "useSystemPalette" + + fun saveUseSystemPalette(context: Context, enabled: Boolean) { + context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit() + .putBoolean(USE_SYSTEM_PALETTE, enabled).apply() + } + + fun background(context: Context): Int = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + context.getColor(R.color.widget_system_surface) + } else { + BuildingColors.widgetBackground(context) + } + + fun text(context: Context): Int = context.getColor( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) R.color.widget_system_text + else R.color.widget_text, + ) + + fun muted(context: Context): Int = context.getColor( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) R.color.widget_system_muted + else R.color.widget_muted, + ) + + fun accent(context: Context): Int = context.getColor( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) R.color.widget_system_accent + else R.color.widget_accent, + ) + + /** Preserve campus grouping with three system accent families and tonal variations. */ + fun course(context: Context, area: String?): IntArray? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return null + if (!context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + .getBoolean(USE_SYSTEM_PALETTE, false)) return null + val dark = context.resources.configuration.uiMode and + Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES + val colors = if (dark) { + when (area) { + "northTeaching" -> intArrayOf( + android.R.color.system_accent1_700, android.R.color.system_accent1_100, + ) + "wencui" -> intArrayOf( + android.R.color.system_accent2_700, android.R.color.system_accent2_100, + ) + "southScience" -> intArrayOf( + android.R.color.system_accent3_700, android.R.color.system_accent3_100, + ) + "eastFacilities" -> intArrayOf( + android.R.color.system_accent1_800, android.R.color.system_accent1_100, + ) + "southEngineering" -> intArrayOf( + android.R.color.system_accent3_800, android.R.color.system_accent3_100, + ) + else -> intArrayOf( + android.R.color.system_neutral2_700, android.R.color.system_neutral2_100, + ) + } + } else { + when (area) { + "northTeaching" -> intArrayOf( + android.R.color.system_accent1_100, android.R.color.system_accent1_900, + ) + "wencui" -> intArrayOf( + android.R.color.system_accent2_100, android.R.color.system_accent2_900, + ) + "southScience" -> intArrayOf( + android.R.color.system_accent3_100, android.R.color.system_accent3_900, + ) + "eastFacilities" -> intArrayOf( + android.R.color.system_accent1_50, android.R.color.system_accent1_900, + ) + "southEngineering" -> intArrayOf( + android.R.color.system_accent3_50, android.R.color.system_accent3_900, + ) + else -> intArrayOf( + android.R.color.system_neutral2_100, android.R.color.system_neutral2_900, + ) + } + } + return colors.map { context.getColor(it) }.toIntArray() + } +} diff --git a/android/app/src/main/res/layout/timetable_widget.xml b/android/app/src/main/res/layout/timetable_widget.xml index b767917..f4e1a72 100644 --- a/android/app/src/main/res/layout/timetable_widget.xml +++ b/android/app/src/main/res/layout/timetable_widget.xml @@ -1,41 +1,46 @@ - - - - + + - - - + android:layout_height="0dp" + android:layout_weight="1" + android:layout_marginTop="6dp"> + + + + + diff --git a/android/app/src/main/res/values-night-v31/widget_system_colors.xml b/android/app/src/main/res/values-night-v31/widget_system_colors.xml new file mode 100644 index 0000000..c5b28ea --- /dev/null +++ b/android/app/src/main/res/values-night-v31/widget_system_colors.xml @@ -0,0 +1,7 @@ + + + @android:color/system_neutral1_900 + @android:color/system_neutral1_100 + @android:color/system_neutral2_200 + @android:color/system_accent1_200 + diff --git a/android/app/src/main/res/values-v31/widget_system_colors.xml b/android/app/src/main/res/values-v31/widget_system_colors.xml new file mode 100644 index 0000000..9e08145 --- /dev/null +++ b/android/app/src/main/res/values-v31/widget_system_colors.xml @@ -0,0 +1,7 @@ + + + @android:color/system_neutral1_10 + @android:color/system_neutral1_900 + @android:color/system_neutral2_700 + @android:color/system_accent1_600 + diff --git a/android/app/src/main/res/values/widget_system_colors.xml b/android/app/src/main/res/values/widget_system_colors.xml new file mode 100644 index 0000000..760c344 --- /dev/null +++ b/android/app/src/main/res/values/widget_system_colors.xml @@ -0,0 +1,7 @@ + + + @color/widget_surface + @color/widget_text + @color/widget_muted + @color/widget_accent + diff --git a/assets/palettes/frappe.json b/assets/palettes/frappe.json new file mode 100644 index 0000000..d820429 --- /dev/null +++ b/assets/palettes/frappe.json @@ -0,0 +1,13 @@ +{ + "name": "Frappé", + "brightness": "dark", + "base": "#303446", + "text": "#C6D0F5", + "northTeaching": "#8CAAEE", + "wencui": "#A6D189", + "southScience": "#E5C890", + "eastFacilities": "#CA9EE6", + "southEngineering": "#EF9F76", + "neutral": "#B5BFE2", + "agenda": "#BABBF1" +} diff --git a/assets/palettes/macchiato.json b/assets/palettes/macchiato.json new file mode 100644 index 0000000..1e65f25 --- /dev/null +++ b/assets/palettes/macchiato.json @@ -0,0 +1,13 @@ +{ + "name": "Macchiato", + "brightness": "dark", + "base": "#24273A", + "text": "#CAD3F5", + "northTeaching": "#8AADF4", + "wencui": "#A6DA95", + "southScience": "#EED49F", + "eastFacilities": "#C6A0F6", + "southEngineering": "#F5A97F", + "neutral": "#B8C0E0", + "agenda": "#B7BDF8" +} diff --git a/assets/palettes/mocha.json b/assets/palettes/mocha.json new file mode 100644 index 0000000..11179b6 --- /dev/null +++ b/assets/palettes/mocha.json @@ -0,0 +1,13 @@ +{ + "name": "Mocha", + "brightness": "dark", + "base": "#1E1E2E", + "text": "#CDD6F4", + "northTeaching": "#89B4FA", + "wencui": "#A6E3A1", + "southScience": "#F9E2AF", + "eastFacilities": "#CBA6F7", + "southEngineering": "#FAB387", + "neutral": "#BAC2DE", + "agenda": "#B4BEFE" +} diff --git a/docs/architecture.md b/docs/architecture.md index d59f7b0..abecb69 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -69,7 +69,13 @@ DDL 绘制与布局分离:右端为 `dueAt`,左侧渐隐,屏幕宽度为 时间轴以视口中心时间和连续缩放比例计算坐标,不创建覆盖历史与未来的巨大滚动画布。默认一天一格,最多一小时一格;基准格宽为 48~84 逻辑像素。平移没有预设日期边界,只绘制可见区域附近的网格和条目。顶部工具栏只保留单行并排的「今天」按钮与自适应宽度的缩放滑条,不显示当前日期和常驻的「时间/格」文本,缩放比例仅保留无障碍语义描述。表头基准高度为 24 逻辑像素:不显示小时时,第一行显示日期、第二行居中显示星期;显示小时时,第一行将「日期 星期」作为一个整体居中,第二行显示小时。标签宽度测量包含完整星期与中间空格,固定在对应时间格的中上方,以完整文字绘制并在视口边缘自然裁切,不贴靠屏幕边缘或变成省略号。首个日程条距表头分隔线 12 逻辑像素,与日程条之间的轨道间距一致。工具栏不再包含问号帮助图标及其提示。当前时间线单独在前景绘制,覆盖日程条但不拦截交互,也不绘制圆点。条目单击复用日程详情弹层,编辑、删除、同步后由功能控制器通知刷新。 -仅在时间轴中,手动创建的 DDL 使用绿色(文萃楼调色板),考试使用橙色,乐学 DDL 使用黄绿色(工程训练中心调色板),新延河课堂 DDL 使用青绿色,大物实验 DDL 使用紫色。颜色直接引用现有楼宇调色板对应的浅色/深色 `fill`、`text`、`accent`:背景使用 fill、文字使用 text、DDL 右侧条与其他日程边框使用 accent。未完成且逾期的 DDL 优先使用主题的 errorContainer/onErrorContainer/error,不再保留来源色。已完成 DDL 和已结束考试的背景、文字、图标及边框/右侧条统一使用 50% 不透明度(50% 透明),保留各自原色;普通活动的完成样式不变。列表和课表不使用此来源映射。 +仅在时间轴中,考试使用橙色,乐学 DDL 使用黄绿色(工程训练中心调色板),新延河课堂 DDL 使用青绿色,大物实验 DDL 使用紫色。颜色直接引用现有楼宇调色板对应的浅色/深色 `fill`、`text`、`accent`:背景使用 fill、文字使用 text、DDL 右侧条与其他日程边框使用 accent。未完成且逾期的 DDL 保留来源的 fill/text/accent,仅把右侧条改为主题的 error,不再整块使用 errorContainer。已完成 DDL 和已结束考试的背景、文字、图标及边框/右侧条统一使用 50% 不透明度(50% 透明),保留各自原色;普通活动的完成样式不变。列表和课表不使用此来源映射。 + +主页课表网格中的日程块不复用时间轴的楼宇来源色:普通活动使用 `primaryContainer` 背景、`onPrimaryContainer` 文字和 1px `primary` 边框,考试使用 `errorContainer`/`onErrorContainer`/`error`。浅色模式下容器色与页面底色天然接近,可见性由 tone 固定的边框和文字承担,因此开启全局系统调色板后仍能辨认;不使用 `outlineVariant` 等低对比细分隔色。 + +关闭全局系统调色板时,所选 JSON 调色板只覆盖课程与普通日程块,不修改页面背景、全局 `ColorScheme` 或应用明暗模式。内置 Mocha/Macchiato/Frappé 位于 `assets/palettes/`,颜色来自 [Catppuccin palette](https://github.com/catppuccin/palette/blob/main/palette.json),均适用于深色模式。课程使用所属区域的强调色,普通日程使用 `agenda`;最终底色统一为 `Color.lerp(base, accent, 0.24)`,文字统一使用 `text`。主页普通日程块与时间轴普通活动、无平台来源的手动 DDL 使用同一组颜色。时间轴中的平台作业、MOOC、物理实验与考试继续保留来源映射,逾期右侧条和主页考试沿用错误色,完成淡化规则不变。系统调色板可用且已启用时优先于 JSON 配色。 +设置的「外观」二级页面提供明暗模式切换、浅色/深色调色板独立选择、JSON 文件导入、自定义配色的删除按钮及系统调色板开关,所有选项均可直接使用。关于弹窗中的五次连击仍可开启本次运行的开发者模式,但目前不影响任何功能。导入成功后将完整配色内容复制到应用设置并应用到对应模式,不依赖原文件继续存在;校验或保存失败时保留原选择。删除正在使用的自定义配色会将对应模式恢复为默认。旧版本的 Cat 配色选择会自动恢复。Android 小组件接收浅色和深色课程的最终颜色,其系统课程配色开关仍独立控制;Android 12+ 的组件整体背景与文字继续使用系统颜色。文件格式与字段说明见 [调色板文件](palettes.md)。 +设置的「外观」二级页面提供明暗模式切换、浅色/深色调色板独立选择、JSON 文件导入、自定义配色的删除按钮及系统调色板开关,所有选项均可直接使用。关于弹窗中的五次连击仍可开启本次运行的开发者模式,但目前不影响任何功能。导入成功后将完整配色内容复制到应用设置并应用到对应模式,不依赖原文件继续存在;校验或保存失败时保留原选择。删除正在使用的自定义配色会将对应模式恢复为默认。旧版本的 Cat 配色选择会自动恢复。Android 小组件接收浅色和深色课程的最终颜色,其系统课程配色开关仍独立控制;Android 12+ 的组件整体背景与文字继续使用系统颜色。文件格式与字段说明见 [调色板文件](palettes.md)。 操作方式:拖动或普通滚轮横向浏览时间,双指捏合、`Ctrl + 滚轮` 或滑条连续缩放;上下拖动或 `Shift + 滚轮` 浏览泳道。时间轴隐藏显式与桌面自动添加的纵向滚动条,仍保留纵向滚动和惯性。单指或鼠标拖动释放后,使用 Flutter 的摩擦物理模拟继续滑行;缩放、新输入、隐藏视图或离开页面时停止惯性,触控板自身的惯性输入不额外叠加甩动。「今天」按钮回到今日居中。日程列表的完成状态以分段选择过滤,不使用横向分页;时间轴的横拖只用于时间导航。 diff --git a/docs/palettes.md b/docs/palettes.md new file mode 100644 index 0000000..7dedaf6 --- /dev/null +++ b/docs/palettes.md @@ -0,0 +1,39 @@ +# 调色板文件 + +在「设置 → 外观」中选择「导入调色板」。每个 UTF-8 JSON 文件定义一个适用于浅色或深色模式的配色: + +```json +{ + "name": "Mocha", + "brightness": "dark", + "base": "#1E1E2E", + "text": "#CDD6F4", + "northTeaching": "#89B4FA", + "wencui": "#A6E3A1", + "southScience": "#F9E2AF", + "eastFacilities": "#CBA6F7", + "southEngineering": "#FAB387", + "neutral": "#BAC2DE", + "agenda": "#B4BEFE" +} +``` + +所有字段均必填。`name` 为非空字符串,`brightness` 只接受 `light` 或 `dark`,所有颜色只接受不透明的 `#RRGGBB` 格式(大小写均可)。`brightness` 表示配色适用的模式,导入文件不会切换应用的明暗模式。 + +| 字段 | 用途 | +| --- | --- | +| `base` | 课程和普通日程混色的基础色 | +| `text` | 所有这些块的文字颜色 | +| `northTeaching` | 北部教学区课程强调色 | +| `wencui` | 文萃楼课程强调色 | +| `southScience` | 南部理学区课程强调色 | +| `eastFacilities` | 东部设施区课程强调色 | +| `southEngineering` | 南部工学区课程强调色 | +| `neutral` | 无法识别区域的课程强调色 | +| `agenda` | 普通日程及手动 DDL 强调色 | + +底色统一为 `base` 向对应强调色混入 24% 的结果,边框使用强调色,文字使用 `text`。浅色配色通常应选择浅基础色、深文字和深强调色,深色配色通常相反。 + +浅色和深色模式分别保存所选配色。导入成功后自动选中对应模式的配色;应用内保存完整内容,可以移动或删除原文件。导入失败或取消选择文件会保留原选择。每个自定义配色旁有删除按钮;删除正在使用的配色后,该模式恢复为默认。内置配色无法删除。 + +配色只影响课程和普通日程块;页面背景、全局控件、主页考试和时间轴平台来源色沿用原有规则。启用全局系统调色板且系统提供可用颜色时,系统颜色优先。Android 小组件独立的系统课程配色开关同样具有优先级。 diff --git a/docs/ui-design.md b/docs/ui-design.md index a17be96..a52dbb4 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -77,6 +77,25 @@ 深色课表将楼栋底色向同色系强调色提亮,课程块、地点文字与进度填充均使用 不透明色。当天列使用中性表面色,避免铜色罩染;日程时间轴沿用原有配色和渐变。 +设置首页提供“外观”入口,明暗模式、浅色/深色调色板、配色导入与删除以及系统调色板 +开关统一位于该二级页面,均可直接使用。在“关于 BITree”弹窗中连续点击图标 5 次 +(相邻点击不超过 2 秒)仍可解锁本次运行的开发者模式;该状态不持久化,目前不影响功能。 +调色板选项独立保存,重启后仍应用已选配色。 +默认选项保留原有楼栋配色;Mocha、Macchiato、Frappé 使用 +[Catppuccin](https://github.com/catppuccin/palette) 的 Base、Text 和片区强调色, +依次以蓝、绿、黄、紫、桃色对应北部教学、文萃、南部理科、东部设施和南部工科, +未知地点使用中性色。底色向强调色混合 24%,已上时段再混合 16%,次要文字 +从底色向正文色混合 80%。这些选项作用于应用和 Android 小组件中的深色课程块。 +Android 12 及以上,小组件背景、标题、次要文字和主题强调色始终读取系统动态色, +不受课程调色板或开关影响。“外观”中的“小组件使用系统调色板”默认关闭, +关闭时课程沿用自选调色板;开启时仅小组件课程改用系统色,应用内课程保持原样。 +系统课程色按片区使用三组强调色,并通过明度区分同组片区;未知地点使用中性色。 +Android 12 以下保留原有背景和课程配色,深色背景可回退到所选 Catppuccin 的 Base。 +应用全局背景、当天列和非课程日程的配色保持原样。 +切换调色板或开关会同步已选课程色值和开关状态,并立即刷新小组件;应用启动时也会同步。 +Android 端保存这些设置,在应用关闭后刷新时读取当前系统颜色。 +调色板选择和开关都持久化,开发者模式的解锁状态不持久化。 + 当天课程保留 1.5px 同色系轮廓;正在上的课程用本楼栋底色与强调色 混合填充已上完的部分。轮廓不占文字空间。时间线独立绘制在课表上层,贯穿七列: 当天保持 2px 原色,两端略收进卡片边缘,避免在侧边间隙露出短点。 diff --git a/lib/agenda/ui/calendar_block.dart b/lib/agenda/ui/calendar_block.dart index 2cc8752..44b1543 100644 --- a/lib/agenda/ui/calendar_block.dart +++ b/lib/agenda/ui/calendar_block.dart @@ -5,6 +5,7 @@ import '../../l10n/formats.dart'; import '../../l10n/l10n.dart'; import '../../schedule/ui/conflicting_courses.dart'; import '../../schedule/ui/course_tile.dart'; +import '../../ui/course_palette.dart'; import '../../ui/design.dart'; import '../calendar.dart'; import '../model.dart'; @@ -43,12 +44,26 @@ class CalendarBlockTile extends StatelessWidget { date: items.first.startsAt, ); } - final scheme = Theme.of(context).colorScheme; + final theme = Theme.of(context); + final scheme = theme.colorScheme; final exam = items.any((item) => item.entry?.kind == AgendaKind.exam); + final palette = agendaPaletteColors(theme); + // The outline carries contrast for subtle fills in both palette modes. final background = exam ? scheme.errorContainer - : scheme.surfaceContainerLow; - final foreground = exam ? scheme.onErrorContainer : scheme.onSurface; + : palette == null + ? scheme.primaryContainer + : Color(palette.fill); + final foreground = exam + ? scheme.onErrorContainer + : palette == null + ? scheme.onPrimaryContainer + : Color(palette.text); + final outline = exam + ? scheme.error + : palette == null + ? scheme.primary + : Color(palette.accent); final summary = items .map( (item) => @@ -67,10 +82,7 @@ class CalendarBlockTile extends StatelessWidget { color: background, shape: RoundedRectangleBorder( borderRadius: AppCorners.compact, - side: BorderSide( - color: exam ? scheme.error : scheme.outlineVariant, - width: exam ? 1 : 0.7, - ), + side: BorderSide(color: outline, width: 1), ), clipBehavior: Clip.antiAlias, child: InkWell( diff --git a/lib/agenda/ui/timeline_colors.dart b/lib/agenda/ui/timeline_colors.dart index 9888441..cfcc2de 100644 --- a/lib/agenda/ui/timeline_colors.dart +++ b/lib/agenda/ui/timeline_colors.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../schedule/building_palette.dart'; +import '../../ui/course_palette.dart'; import '../deadline_source.dart'; import '../model.dart'; import '../physics_lab_deadlines.dart'; @@ -13,16 +14,6 @@ import '../physics_lab_deadlines.dart'; ) { final theme = Theme.of(context); final scheme = theme.colorScheme; - // Pending, overdue DDLs take priority over source colors. Completed DDLs - // cannot be overdue, so they retain the faded source palette below. - if (entry.overdue(now)) { - return ( - fill: scheme.errorContainer, - text: scheme.onErrorContainer, - accent: scheme.error, - ); - } - final paletteName = switch (entry.kind) { AgendaKind.exam => '物理实验中心', AgendaKind.deadline => @@ -32,7 +23,7 @@ import '../physics_lab_deadlines.dart'; DeadlineSource.lexue => '工程训练中心', DeadlineSource.eclass => '文化体育中心', DeadlineSource.mooc => '文萃楼A', - null => '文萃楼', + null => null, }, AgendaKind.event => null, }; @@ -45,6 +36,7 @@ import '../physics_lab_deadlines.dart'; final fillAlpha = fadeAll ? 0.5 : (finished ? 0.55 : 1.0); final foregroundAlpha = fadeAll ? 0.5 : 1.0; if (palette != null) { + // Keep source colours recognizable even when the app uses system colours. final values = theme.brightness == Brightness.dark ? palette.dark : palette.light; @@ -55,6 +47,15 @@ import '../physics_lab_deadlines.dart'; ); } + final blockPalette = agendaPaletteColors(theme); + if (blockPalette != null) { + return ( + fill: Color(blockPalette.fill).withValues(alpha: fillAlpha), + text: Color(blockPalette.text).withValues(alpha: foregroundAlpha), + accent: Color(blockPalette.accent).withValues(alpha: foregroundAlpha), + ); + } + final fill = finished ? scheme.surfaceContainerHighest : switch (entry.kind) { diff --git a/lib/agenda/ui/timeline_entry.dart b/lib/agenda/ui/timeline_entry.dart index b059efa..2199f7f 100644 --- a/lib/agenda/ui/timeline_entry.dart +++ b/lib/agenda/ui/timeline_entry.dart @@ -108,7 +108,9 @@ class AgendaTimelineEntry extends StatelessWidget { border: entry.isDeadline ? Border( right: BorderSide( - color: colors.accent, + color: entry.overdue(now) + ? Theme.of(context).colorScheme.error + : colors.accent, width: 3, ), ) diff --git a/lib/app/controller.dart b/lib/app/controller.dart index 5a18c1c..1cd6570 100644 --- a/lib/app/controller.dart +++ b/lib/app/controller.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -6,6 +8,8 @@ import '../agenda/controller.dart'; import '../agenda/view_mode.dart'; import '../classrooms/repository.dart'; import '../core/diagnostics.dart'; +import '../core/platform/desktop_widget.dart'; +import '../core/platform/system_palette.dart'; import '../mooc/controller.dart'; import '../mooc/storage.dart'; import '../schedule/controller.dart'; @@ -14,6 +18,8 @@ import '../school/controller.dart'; import '../school/service.dart'; import '../school/storage.dart'; import '../sync/controller.dart'; +import '../ui/course_palette.dart'; +import '../ui/system_palette.dart'; enum AppLanguage { system, @@ -37,8 +43,12 @@ class AppController extends ChangeNotifier { required this._preferences, required this.agenda, required this.academics, + SystemPaletteSource? systemPaletteSource, + Future> Function()? coursePaletteLoader, MoocStorage? moocStorage, - }) : timetable = TimetableController(store) { + }) : _coursePaletteLoader = coursePaletteLoader ?? CoursePalette.loadBuiltins, + _systemPaletteSource = systemPaletteSource ?? SystemPaletteSource(), + timetable = TimetableController(store) { this.school = SchoolController( service: school, storage: schoolStorage, @@ -47,6 +57,7 @@ class AppController extends ChangeNotifier { academics: academics, ); classrooms = ClassroomRepository(this.school); + _systemPaletteSource.watch(() => _refreshSystemPalette()); mooc = MoocController( storage: moocStorage ?? MoocStorage(), agenda: agenda, @@ -73,10 +84,41 @@ class AppController extends ChangeNotifier { bool _agendaViewChosen = false; bool _ready = false; ThemeMode _themeMode = ThemeMode.system; - AppLanguage _language = AppLanguage.system; + bool _developerMode = false; + bool _widgetUseSystemPalette = false; + final Future> Function() _coursePaletteLoader; + final Map _palettes = {}; + final Map _paletteSelections = {}; + Future? _paletteSettingsLoad; + Future _coursePaletteWrite = Future.value(); + final SystemPaletteSource _systemPaletteSource; + bool _globalUseSystemPalette = false; + bool _globalPaletteChosen = false; + bool _disposed = false; + int _paletteRequest = 0; + SystemPalette? _systemPalette; + Future _globalPaletteWrite = Future.value(); bool get ready => _ready; ThemeMode get themeMode => _themeMode; + bool get developerMode => _developerMode; + bool get widgetUseSystemPalette => _widgetUseSystemPalette; + Map palettesFor(Brightness brightness) => { + for (final entry in _palettes.entries) + if (entry.value.brightness == brightness) entry.key: entry.value, + }; + Map get customPalettes => { + for (final entry in _palettes.entries) + if (!CoursePalette.builtinIds.contains(entry.key)) entry.key: entry.value, + }; + String? paletteIdFor(Brightness brightness) => _paletteSelections[brightness]; + CoursePalette? paletteFor(Brightness brightness) => + _palettes[paletteIdFor(brightness)]; + bool get globalUseSystemPalette => _globalUseSystemPalette; + SystemPalette? get systemPalette => + _globalUseSystemPalette ? _systemPalette : null; + AppLanguage _language = AppLanguage.system; + AppLanguage get language => _language; Future initialize() async { @@ -103,6 +145,26 @@ class AppController extends ChangeNotifier { } catch (failure, stack) { AppDiagnostics.instance.report('读取语言设置', failure, stack); } + await loadCoursePalettes(); + try { + _widgetUseSystemPalette = + await _preferences.getBool('widgetUseSystemPalette') ?? false; + } catch (failure, stack) { + AppDiagnostics.instance.report('读取小组件调色板设置', failure, stack); + } + try { + final enabled = + await _preferences.getBool('globalUseSystemPalette') ?? false; + if (!_globalPaletteChosen) _globalUseSystemPalette = enabled; + } catch (failure, stack) { + AppDiagnostics.instance.report('读取全局调色板设置', failure, stack); + } + await _refreshSystemPalette(); + await updateDesktopWidget( + lightCourseColors: paletteFor(Brightness.light)?.widgetColors ?? {}, + darkCourseColors: paletteFor(Brightness.dark)?.widgetColors ?? {}, + useSystemPalette: _widgetUseSystemPalette, + ); try { final savedView = await _preferences.getString('agendaViewMode'); if (!_agendaViewChosen) { @@ -140,6 +202,183 @@ class AppController extends ChangeNotifier { } } + void unlockDeveloperMode() { + if (_developerMode) return; + _developerMode = true; + notifyListeners(); + } + + Future loadCoursePalettes() => + _paletteSettingsLoad ??= _loadCoursePalettes(); + + Future _loadCoursePalettes() async { + try { + _palettes.addAll(await _coursePaletteLoader()); + final saved = await _preferences.getString('coursePalettes'); + if (saved == null) { + // Preserve choices made before JSON palettes were introduced. + final legacy = await _preferences.getString('darkCoursePalette'); + if (_palettes.containsKey(legacy)) { + _paletteSelections[Brightness.dark] = legacy; + } + } else { + final json = jsonDecode(saved) as Map; + final custom = json['custom'] as Map; + for (final entry in custom.entries) { + try { + if (CoursePalette.builtinIds.contains(entry.key)) continue; + _palettes[entry.key] = CoursePalette.fromJson(entry.value); + } catch (failure, stack) { + AppDiagnostics.instance.report('读取自定义配色', failure, stack); + } + } + for (final brightness in Brightness.values) { + final id = json[brightness.name]; + if (id is String && _palettes[id]?.brightness == brightness) { + _paletteSelections[brightness] = id; + } + } + } + } catch (failure, stack) { + AppDiagnostics.instance.report('读取调色板设置', failure, stack); + } + if (!_disposed) notifyListeners(); + } + + Future _saveCoursePalettes( + Map palettes, + Map selections, + ) => _preferences.setString( + 'coursePalettes', + jsonEncode({ + for (final brightness in Brightness.values) + brightness.name: selections[brightness], + 'custom': { + for (final entry in palettes.entries) + if (!CoursePalette.builtinIds.contains(entry.key)) + entry.key: entry.value.toJson(), + }, + }), + ); + + Future _syncWidgetPalettes() => updateDesktopWidget( + lightCourseColors: paletteFor(Brightness.light)?.widgetColors ?? {}, + darkCourseColors: paletteFor(Brightness.dark)?.widgetColors ?? {}, + useSystemPalette: _widgetUseSystemPalette, + ); + + // One queue keeps imports, deletions and rapid selections in order. Keep the + // queue usable after a failed write while allowing import failures to reach UI. + Future _queuePaletteWrite(Future Function() operation) { + final result = _coursePaletteWrite.then((_) => operation()); + _coursePaletteWrite = result.then( + (_) {}, + onError: (Object _, StackTrace _) {}, + ); + return result; + } + + Future setCoursePalette(Brightness brightness, String? id) => + _queuePaletteWrite(() async { + await loadCoursePalettes(); + if (id != null && _palettes[id]?.brightness != brightness) { + throw const FormatException('此配色不适用于所选的明暗模式。'); + } + final selections = {..._paletteSelections, brightness: id}; + try { + await _saveCoursePalettes(_palettes, selections); + _paletteSelections[brightness] = id; + if (!_disposed) notifyListeners(); + await _syncWidgetPalettes(); + } catch (failure, stack) { + AppDiagnostics.instance.report('保存调色板设置', failure, stack); + } + }); + + Future importCoursePalette(String source) async { + // Validate the whole file before touching either selection or persistence. + final palette = CoursePalette.parse(source); + await _queuePaletteWrite(() async { + await loadCoursePalettes(); + var suffix = DateTime.now().microsecondsSinceEpoch; + while (_palettes.containsKey('custom-$suffix')) { + suffix++; + } + final id = 'custom-$suffix'; + final palettes = {..._palettes, id: palette}; + final selections = {..._paletteSelections, palette.brightness: id}; + await _saveCoursePalettes(palettes, selections); + _palettes[id] = palette; + _paletteSelections[palette.brightness] = id; + if (!_disposed) notifyListeners(); + await _syncWidgetPalettes(); + }); + } + + Future deleteCoursePalette(String id) => _queuePaletteWrite(() async { + await loadCoursePalettes(); + if (!customPalettes.containsKey(id)) return; + final palettes = {..._palettes}..remove(id); + final selections = { + for (final entry in _paletteSelections.entries) + entry.key: entry.value == id ? null : entry.value, + }; + try { + await _saveCoursePalettes(palettes, selections); + _palettes.remove(id); + _paletteSelections + ..clear() + ..addAll(selections); + if (!_disposed) notifyListeners(); + await _syncWidgetPalettes(); + } catch (failure, stack) { + AppDiagnostics.instance.report('删除自定义配色', failure, stack); + } + }); + + Future setWidgetUseSystemPalette(bool value) => + _queuePaletteWrite(() async { + try { + await _preferences.setBool('widgetUseSystemPalette', value); + _widgetUseSystemPalette = value; + if (!_disposed) notifyListeners(); + await _syncWidgetPalettes(); + } catch (failure, stack) { + AppDiagnostics.instance.report('保存小组件调色板设置', failure, stack); + } + }); + + Future setGlobalUseSystemPalette(bool value) async { + _globalPaletteChosen = true; + _globalUseSystemPalette = value; + // Invalidate pending platform reads when the switch changes. + _paletteRequest++; + notifyListeners(); + final refresh = _refreshSystemPalette(); + _globalPaletteWrite = _globalPaletteWrite.then((_) async { + try { + await _preferences.setBool('globalUseSystemPalette', value); + } catch (failure, stack) { + AppDiagnostics.instance.report('保存全局调色板设置', failure, stack); + } + }); + await Future.wait([refresh, _globalPaletteWrite]); + } + + Future _refreshSystemPalette() async { + if (_disposed || !_globalUseSystemPalette) return; + final request = ++_paletteRequest; + try { + final palette = await _systemPaletteSource.load(); + if (_disposed || request != _paletteRequest) return; + _systemPalette = palette; + notifyListeners(); + } catch (failure, stack) { + // Keep the last working palette, or the original app theme on first load. + AppDiagnostics.instance.report('读取系统调色板', failure, stack); + } + } + /// Publish immediately and serialize writes so rapid toggles cannot save an /// older choice after a newer one. Future setAgendaViewMode(AgendaViewMode value) { @@ -163,6 +402,9 @@ class AppController extends ChangeNotifier { @override void dispose() { + _disposed = true; + _paletteRequest++; + _systemPaletteSource.dispose(); agendaViewMode.dispose(); sync.dispose(); classrooms.dispose(); diff --git a/lib/app/ui/appearance_screen.dart b/lib/app/ui/appearance_screen.dart new file mode 100644 index 0000000..10ff23a --- /dev/null +++ b/lib/app/ui/appearance_screen.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import '../controller.dart'; +import 'palette_settings.dart'; + +class AppearanceScreen extends StatelessWidget { + const AppearanceScreen({super.key, required this.controller}); + + final AppController controller; + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('外观')), + body: SafeArea( + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 960), + child: ListenableBuilder( + listenable: controller, + builder: (context, _) => ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + children: [ + SegmentedButton( + expandedInsets: EdgeInsets.zero, + showSelectedIcon: false, + segments: const [ + ButtonSegment( + value: ThemeMode.system, + label: Text( + '跟随系统', + maxLines: 2, + textAlign: TextAlign.center, + ), + ), + ButtonSegment(value: ThemeMode.light, label: Text('浅色')), + ButtonSegment(value: ThemeMode.dark, label: Text('深色')), + ], + selected: {controller.themeMode}, + onSelectionChanged: (selection) => + controller.setTheme(selection.single), + ), + const SizedBox(height: 16), + PaletteSettings(controller: controller), + SwitchListTile( + contentPadding: const EdgeInsets.symmetric(vertical: 2), + secondary: const Icon(Icons.auto_awesome_outlined), + title: const Text('小组件使用系统调色板'), + value: controller.widgetUseSystemPalette, + onChanged: controller.setWidgetUseSystemPalette, + ), + SwitchListTile( + contentPadding: const EdgeInsets.symmetric(vertical: 2), + secondary: const Icon(Icons.color_lens_outlined), + title: const Text('全局系统调色板(实验性)'), + value: controller.globalUseSystemPalette, + onChanged: controller.setGlobalUseSystemPalette, + ), + ], + ), + ), + ), + ), + ), + ); +} diff --git a/lib/app/ui/palette_settings.dart b/lib/app/ui/palette_settings.dart new file mode 100644 index 0000000..de7bc6a --- /dev/null +++ b/lib/app/ui/palette_settings.dart @@ -0,0 +1,112 @@ +import 'dart:convert'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; + +import '../../core/diagnostics.dart'; +import '../../ui/app_menu_anchor.dart'; +import '../../ui/app_menu_trigger.dart'; +import '../../ui/app_notice.dart'; +import '../controller.dart'; + +class PaletteSettings extends StatefulWidget { + const PaletteSettings({super.key, required this.controller}); + + final AppController controller; + + @override + State createState() => _PaletteSettingsState(); +} + +class _PaletteSettingsState extends State { + bool _importing = false; + + Future _import() async { + setState(() => _importing = true); + try { + final file = await FilePicker.pickFile( + dialogTitle: '导入调色板', + type: FileType.custom, + allowedExtensions: ['json'], + ); + if (file == null || !mounted) return; + final source = utf8.decode(await file.readAsBytes()); + if (!mounted) return; + await widget.controller.importCoursePalette(source); + if (mounted) showAppNotice(context, '已导入并应用配色。'); + } catch (failure, stack) { + AppDiagnostics.instance.report('导入调色板', failure, stack); + } finally { + if (mounted) setState(() => _importing = false); + } + } + + Widget _selection(Brightness brightness) { + final controller = widget.controller; + final selected = controller.paletteIdFor(brightness); + final choices = { + null: '默认', + for (final entry in controller.palettesFor(brightness).entries) + entry.key: entry.value.name, + }; + return LayoutBuilder( + builder: (context, constraints) => AppMenuAnchor( + menuWidth: constraints.maxWidth, + menuChildren: [ + for (final entry in choices.entries) + Semantics( + selected: selected == entry.key, + child: AppMenuItemButton( + onPressed: () => + controller.setCoursePalette(brightness, entry.key), + trailingIcon: selected == entry.key + ? const Icon(Icons.check_rounded, size: 18) + : const SizedBox(width: 18), + child: Text(entry.value), + ), + ), + ], + builder: (context, menu, isOpen) => ListTile( + contentPadding: const EdgeInsets.symmetric(vertical: 2), + leading: const Icon(Icons.palette_outlined), + title: Text(brightness == Brightness.dark ? '深色模式调色板' : '浅色模式调色板'), + subtitle: Text(controller.paletteFor(brightness)?.name ?? '默认'), + trailing: AppMenuArrow(isOpen: isOpen), + onTap: () => isOpen ? menu.close() : menu.open(), + ), + ), + ); + } + + @override + Widget build(BuildContext context) => Column( + children: [ + _selection(Brightness.light), + _selection(Brightness.dark), + ListTile( + contentPadding: const EdgeInsets.symmetric(vertical: 2), + leading: const Icon(Icons.file_open_outlined), + title: const Text('导入调色板'), + enabled: !_importing, + onTap: _importing ? null : _import, + ), + for (final entry in widget.controller.customPalettes.entries) + ListTile( + contentPadding: const EdgeInsets.symmetric(vertical: 2), + title: Text(entry.value.name), + subtitle: Text( + entry.value.brightness == Brightness.dark ? '深色' : '浅色', + ), + onTap: () => widget.controller.setCoursePalette( + entry.value.brightness, + entry.key, + ), + trailing: IconButton( + tooltip: '删除 ${entry.value.name}', + icon: const Icon(Icons.delete_outline_rounded), + onPressed: () => widget.controller.deleteCoursePalette(entry.key), + ), + ), + ], + ); +} diff --git a/lib/app/ui/settings_screen.dart b/lib/app/ui/settings_screen.dart index 39d1f30..ba686e5 100644 --- a/lib/app/ui/settings_screen.dart +++ b/lib/app/ui/settings_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import '../../agenda/deadline_clear_scope.dart'; @@ -17,6 +19,7 @@ import '../../ui/external_link.dart'; import '../../ui/section_label.dart'; import '../../updates/version.dart'; import '../controller.dart'; +import 'appearance_screen.dart'; import 'logs_screen.dart'; const _projectDescription = '面向北京理工大学的妙妙小工具'; @@ -66,6 +69,9 @@ class SettingsScreen extends StatelessWidget { final builtAt = appBuildTime?.toLocal(); if (!context.mounted) return; + var iconTaps = 0; + Timer? resetTaps; + await showAppDialog( context: context, builder: (dialogContext) { @@ -86,7 +92,38 @@ class SettingsScreen extends StatelessWidget { scrollable: true, title: Row( children: [ - Image.asset('assets/brand/bitree.png', width: 40, height: 40), + Semantics( + button: true, + label: 'BITree 图标', + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + if (controller.developerMode) return; + resetTaps?.cancel(); + resetTaps = Timer(const Duration(seconds: 2), () { + iconTaps = 0; + }); + iconTaps++; + if (iconTaps < 5) return; + controller.unlockDeveloperMode(); + Navigator.pop(dialogContext); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('已开启开发者模式'))); + }, + child: SizedBox( + width: 48, + height: 48, + child: Center( + child: Image.asset( + 'assets/brand/bitree.png', + width: 40, + height: 40, + ), + ), + ), + ), + ), const SizedBox(width: 12), Expanded( child: Column( @@ -192,6 +229,7 @@ class SettingsScreen extends StatelessWidget { ); }, ); + resetTaps?.cancel(); } @override @@ -228,33 +266,25 @@ class SettingsScreen extends StatelessWidget { children: [ SchoolAccount(controller: controller), MoocAccountTile(controller: controller.mooc), - section(l10n.appearance, [ - SegmentedButton( - expandedInsets: EdgeInsets.zero, - showSelectedIcon: false, - segments: [ - ButtonSegment( - value: ThemeMode.system, - label: Text( - l10n.followSystem, - maxLines: 2, - textAlign: TextAlign.center, - ), - ), - ButtonSegment( - value: ThemeMode.light, - label: Text(l10n.themeLight), - ), - ButtonSegment( - value: ThemeMode.dark, - label: Text(l10n.themeDark), + Padding( + padding: const EdgeInsets.only(top: 24), + child: ListTile( + contentPadding: tilePadding, + leading: const Icon(Icons.palette_outlined), + title: const Text('外观'), + subtitle: Text(switch (controller.themeMode) { + ThemeMode.system => '跟随系统', + ThemeMode.light => '浅色', + ThemeMode.dark => '深色', + }), + trailing: const Icon(Icons.chevron_right_rounded, size: 20), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => AppearanceScreen(controller: controller), ), - ], - selected: {controller.themeMode}, - onSelectionChanged: (selection) => - controller.setTheme(selection.single), + ), ), - ]), + ), section(l10n.language, [ SegmentedButton( expandedInsets: EdgeInsets.zero, diff --git a/lib/core/platform/desktop_widget.dart b/lib/core/platform/desktop_widget.dart index 4ca7b1b..651d7cb 100644 --- a/lib/core/platform/desktop_widget.dart +++ b/lib/core/platform/desktop_widget.dart @@ -6,17 +6,33 @@ import 'package:flutter/services.dart'; import '../diagnostics.dart'; /// Refreshes the native widget after a persisted change. A widget failure does -/// not turn a completed data write into a failed save. -void refreshDesktopWidget() { +/// not turn a completed data write into a failed save. Supply course colour maps +/// to persist palettes, or empty maps to restore native default colours. +/// Content-only refreshes leave the saved appearance unchanged. +Future updateDesktopWidget({ + Map>? lightCourseColors, + Map>? darkCourseColors, + bool? useSystemPalette, +}) async { if (!Platform.isAndroid) return; - unawaited(_refresh()); -} - -Future _refresh() async { try { - await const MethodChannel('dev.so1ve.bitree/widget') - .invokeMethod('update'); - } on Exception catch (failure, stack) { - AppDiagnostics.instance.error('更新桌面组件', failure, stack); + await const MethodChannel('dev.so1ve.bitree/widget').invokeMethod( + 'update', + lightCourseColors == null && + darkCourseColors == null && + useSystemPalette == null + ? null + : { + 'lightCourseColors': ?lightCourseColors, + 'darkCourseColors': ?darkCourseColors, + 'useSystemPalette': ?useSystemPalette, + }, + ); + } catch (failure, stack) { + AppDiagnostics.instance.report('更新桌面组件', failure, stack); } } + +void refreshDesktopWidget() { + unawaited(updateDesktopWidget()); +} diff --git a/lib/core/platform/system_palette.dart b/lib/core/platform/system_palette.dart new file mode 100644 index 0000000..ae11707 --- /dev/null +++ b/lib/core/platform/system_palette.dart @@ -0,0 +1,30 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; + +import '../../ui/system_palette.dart'; + +/// Platform boundary kept injectable for unsupported platforms and tests. +class SystemPaletteSource { + static const _channel = MethodChannel('dev.so1ve.bitree/theme'); + bool _watching = false; + + Future load() async { + if (!Platform.isAndroid) return null; + return SystemPalette.fromPlatform( + await _channel.invokeMethod('getSystemPalette'), + ); + } + + void watch(VoidCallback onChanged) { + if (!Platform.isAndroid) return; + _watching = true; + _channel.setMethodCallHandler((call) async { + if (call.method == 'changed') onChanged(); + }); + } + + void dispose() { + if (_watching) _channel.setMethodCallHandler(null); + } +} diff --git a/lib/main.dart b/lib/main.dart index 501c2be..0941fb9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -53,16 +53,21 @@ class BitreeApp extends StatelessWidget { @override Widget build(BuildContext context) { - final lightTheme = bitreeTheme(Brightness.light); - final darkTheme = bitreeTheme(Brightness.dark); - return ListenableBuilder( listenable: controller, builder: (context, _) => MaterialApp( onGenerateTitle: (context) => context.l10n.appName, debugShowCheckedModeBanner: false, - theme: lightTheme, - darkTheme: darkTheme, + theme: bitreeTheme( + Brightness.light, + coursePalette: controller.paletteFor(Brightness.light), + systemPalette: controller.systemPalette, + ), + darkTheme: bitreeTheme( + Brightness.dark, + coursePalette: controller.paletteFor(Brightness.dark), + systemPalette: controller.systemPalette, + ), themeMode: controller.themeMode, locale: controller.language.locale, supportedLocales: AppLocalizations.supportedLocales, diff --git a/lib/ui/course_palette.dart b/lib/ui/course_palette.dart new file mode 100644 index 0000000..656d98b --- /dev/null +++ b/lib/ui/course_palette.dart @@ -0,0 +1,122 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../schedule/building_palette.dart'; +import 'system_palette.dart'; + +/// A block-only palette. Page surfaces and controls keep the app's ColorScheme. +class CoursePalette { + CoursePalette._(this.name, this.brightness, Map colors) + : _colors = Map.unmodifiable(colors); + + factory CoursePalette.parse(String source) { + final Object? json; + try { + json = jsonDecode(source); + } on FormatException { + throw const FormatException('配色文件不是有效的 JSON。'); + } + return CoursePalette.fromJson(json); + } + + factory CoursePalette.fromJson(Object? json) { + if (json is! Map) { + throw const FormatException('配色文件必须是一个 JSON 对象。'); + } + final name = json['name']; + if (name is! String || name.trim().isEmpty) { + throw const FormatException('name 必须是非空名称。'); + } + final brightness = switch (json['brightness']) { + 'light' => Brightness.light, + 'dark' => Brightness.dark, + _ => throw const FormatException('brightness 必须是 light 或 dark。'), + }; + final colors = {}; + for (final key in colorKeys) { + final value = json[key]; + if (value is! String || + value.length != 7 || + !RegExp(r'^#[0-9a-fA-F]{6}$').hasMatch(value)) { + throw FormatException('$key 必须是不透明的 #RRGGBB 颜色。'); + } + colors[key] = 0xFF000000 | int.parse(value.substring(1), radix: 16); + } + return CoursePalette._(name.trim(), brightness, colors); + } + + static const colorKeys = [ + 'base', + 'text', + 'northTeaching', + 'wencui', + 'southScience', + 'eastFacilities', + 'southEngineering', + 'neutral', + 'agenda', + ]; + static const builtinIds = ['mocha', 'macchiato', 'frappe']; + + static Future> loadBuiltins() async => { + for (final id in builtinIds) + id: CoursePalette.parse( + await rootBundle.loadString('assets/palettes/$id.json'), + ), + }; + + final String name; + final Brightness brightness; + final Map _colors; + + Map toJson() => { + 'name': name, + 'brightness': brightness.name, + for (final key in colorKeys) + key: + '#${(_colors[key]! & 0xFFFFFF).toRadixString(16).padLeft(6, '0').toUpperCase()}', + }; + + BuildingColorValues _blockColors(String key) => ( + fill: Color.lerp( + Color(_colors['base']!), + Color(_colors[key]!), + 0.24, + )!.toARGB32(), + text: _colors['text']!, + accent: _colors[key]!, + ); + + BuildingColorValues colorsFor(CampusArea? area) => + _blockColors(area?.name ?? 'neutral'); + + BuildingColorValues get agendaColors => _blockColors('agenda'); + + /// Final opaque course fills, shared with the native widget renderer. + Map> get widgetColors => { + 'surface': [_colors['base']!, _colors['text']!], + for (final area in [...CampusArea.values, null]) + area?.name ?? 'neutral': [colorsFor(area).fill, colorsFor(area).text], + }; +} + +BuildingColorValues? agendaPaletteColors(ThemeData theme) { + if (theme.extension() != null) return null; + return theme.extension()?.palette?.agendaColors; +} + +class CoursePaletteTheme extends ThemeExtension { + const CoursePaletteTheme(this.palette); + + final CoursePalette? palette; + + @override + CoursePaletteTheme copyWith({CoursePalette? palette}) => + CoursePaletteTheme(palette ?? this.palette); + + @override + CoursePaletteTheme lerp(CoursePaletteTheme? other, double t) => + other == null || t < 0.5 ? this : other; +} diff --git a/lib/ui/system_palette.dart b/lib/ui/system_palette.dart new file mode 100644 index 0000000..a90db3c --- /dev/null +++ b/lib/ui/system_palette.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; + +import '../schedule/building_palette.dart'; + +/// Android's five wallpaper-derived tonal palettes, shared by both app themes. +class SystemPalette { + SystemPalette._(this._families); + + static const _tones = [100, 99, 95, 90, 80, 70, 60, 50, 40, 30, 20, 10, 0]; + static const _names = [ + 'accent1', + 'accent2', + 'accent3', + 'neutral1', + 'neutral2', + ]; + final Map> _families; + + static SystemPalette? fromPlatform(Object? value) { + if (value is! Map) return null; + final families = >{}; + for (final name in _names) { + final colors = value[name]; + if (colors is! List || + colors.length != _tones.length || + colors.any((color) => color is! int)) { + return null; + } + families[name] = [for (final color in colors) Color(color as int)]; + } + return SystemPalette._(families); + } + + Color _tone(String family, int tone) { + final colors = _families[family]!; + for (var i = 0; i < _tones.length - 1; i++) { + final upper = _tones[i]; + final lower = _tones[i + 1]; + if (tone <= upper && tone >= lower) { + return Color.lerp( + colors[i], + colors[i + 1], + (upper - tone) / (upper - lower), + )!; + } + } + return colors.last; + } + + ColorScheme colorScheme(Brightness brightness) { + final dark = brightness == Brightness.dark; + Color accent(String family, int light, int night) => + _tone(family, dark ? night : light); + Color neutral(int light, int night) => accent('neutral1', light, night); + Color variant(int light, int night) => accent('neutral2', light, night); + // Keep Material's semantic error roles while replacing all palette roles. + return ColorScheme.fromSeed( + seedColor: _tone('accent1', 40), + brightness: brightness, + ).copyWith( + primary: accent('accent1', 40, 80), + onPrimary: accent('accent1', 100, 20), + primaryContainer: accent('accent1', 90, 30), + onPrimaryContainer: accent('accent1', 10, 90), + secondary: accent('accent2', 40, 80), + onSecondary: accent('accent2', 100, 20), + secondaryContainer: accent('accent2', 90, 30), + onSecondaryContainer: accent('accent2', 10, 90), + tertiary: accent('accent3', 40, 80), + onTertiary: accent('accent3', 100, 20), + tertiaryContainer: accent('accent3', 90, 30), + onTertiaryContainer: accent('accent3', 10, 90), + surface: neutral(98, 6), + onSurface: neutral(10, 90), + onSurfaceVariant: variant(30, 80), + surfaceDim: neutral(87, 6), + surfaceBright: neutral(98, 24), + surfaceContainerLowest: neutral(100, 4), + surfaceContainerLow: neutral(96, 10), + surfaceContainer: neutral(94, 12), + surfaceContainerHigh: neutral(92, 17), + surfaceContainerHighest: neutral(90, 22), + outline: variant(50, 60), + outlineVariant: variant(80, 30), + inverseSurface: neutral(20, 90), + onInverseSurface: neutral(95, 20), + inversePrimary: accent('accent1', 80, 40), + surfaceTint: accent('accent1', 40, 80), + primaryFixed: _tone('accent1', 90), + primaryFixedDim: _tone('accent1', 80), + onPrimaryFixed: _tone('accent1', 10), + onPrimaryFixedVariant: _tone('accent1', 30), + secondaryFixed: _tone('accent2', 90), + secondaryFixedDim: _tone('accent2', 80), + onSecondaryFixed: _tone('accent2', 10), + onSecondaryFixedVariant: _tone('accent2', 30), + tertiaryFixed: _tone('accent3', 90), + tertiaryFixedDim: _tone('accent3', 80), + onTertiaryFixed: _tone('accent3', 10), + onTertiaryFixedVariant: _tone('accent3', 30), + ); + } + + /// Match the widget's campus grouping and tone choices. + ({Color fill, Color text, Color accent}) courseColors( + CampusArea? area, + Brightness brightness, + ) { + final family = switch (area) { + CampusArea.northTeaching || CampusArea.eastFacilities => 'accent1', + CampusArea.wencui => 'accent2', + CampusArea.southScience || CampusArea.southEngineering => 'accent3', + null => 'neutral2', + }; + final dark = brightness == Brightness.dark; + final alternate = + area == CampusArea.eastFacilities || + area == CampusArea.southEngineering; + return ( + fill: _tone(family, dark ? (alternate ? 20 : 30) : (alternate ? 95 : 90)), + text: _tone(family, dark ? 90 : 10), + accent: _tone(family, dark ? 80 : 40), + ); + } +} + +class SystemPaletteTheme extends ThemeExtension { + const SystemPaletteTheme(this.palette); + + final SystemPalette palette; + + @override + SystemPaletteTheme copyWith({SystemPalette? palette}) => + SystemPaletteTheme(palette ?? this.palette); + + @override + SystemPaletteTheme lerp(covariant SystemPaletteTheme? other, double t) => + other == null || t < 0.5 ? this : other; +} diff --git a/lib/ui/theme.dart b/lib/ui/theme.dart index 9c2b8f3..d2df4a0 100644 --- a/lib/ui/theme.dart +++ b/lib/ui/theme.dart @@ -2,13 +2,20 @@ import 'package:flutter/material.dart'; import '../schedule/building_palette.dart'; import '../schedule/model.dart'; +import 'course_palette.dart'; import 'design.dart'; +import 'system_palette.dart'; const copper = Color(0xFFA85E3E); -ThemeData bitreeTheme(Brightness brightness) { +ThemeData bitreeTheme( + Brightness brightness, { + CoursePalette? coursePalette, + SystemPalette? systemPalette, +}) { final dark = brightness == Brightness.dark; final scheme = + systemPalette?.colorScheme(brightness) ?? ColorScheme.fromSeed( seedColor: copper, brightness: brightness, @@ -52,6 +59,12 @@ ThemeData bitreeTheme(Brightness brightness) { ); return ThemeData( useMaterial3: true, + extensions: [ + CoursePaletteTheme( + coursePalette?.brightness == brightness ? coursePalette : null, + ), + if (systemPalette != null) SystemPaletteTheme(systemPalette), + ], colorScheme: scheme, scaffoldBackgroundColor: scheme.surface, fontFamilyFallback: const [ @@ -281,12 +294,25 @@ courseColors(BuildContext context, Course course) { final palette = matchCampusBuilding(course.room, campus: course.campus) ?? unknownBuildingPalette; - final dark = Theme.of(context).brightness == Brightness.dark; - final colors = dark ? palette.dark : palette.light; + final theme = Theme.of(context); + final dark = theme.brightness == Brightness.dark; + final system = theme.extension()?.palette; + if (system != null) { + final colors = system.courseColors(palette.area, theme.brightness); + return ( + fill: colors.fill, + text: colors.text, + secondaryText: Color.lerp(colors.fill, colors.text, 0.8)!, + accent: colors.accent, + elapsedFill: Color.lerp(colors.fill, colors.accent, dark ? 0.16 : 0.32)!, + ); + } + final custom = theme.extension()?.palette; + final colors = + custom?.colorsFor(palette.area) ?? (dark ? palette.dark : palette.light); final accent = Color(colors.accent); - // Solid course blocks need more colour than the subtle timeline fades. - // Resolve the fill here so row bands and today's column cannot tint it. - final fill = dark + // Custom palettes already contain the final mixed block fill. + final fill = dark && custom == null ? Color.lerp(Color(colors.fill), accent, 0.24)! : Color(colors.fill); final text = Color(colors.text); diff --git a/pubspec.yaml b/pubspec.yaml index b4175cb..fba04a7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,3 +39,4 @@ flutter: assets: - assets/brand/bitree.png - assets/wencui/ + - assets/palettes/ diff --git a/test/agenda_timeline_entry_test.dart b/test/agenda_timeline_entry_test.dart new file mode 100644 index 0000000..1b76e7f --- /dev/null +++ b/test/agenda_timeline_entry_test.dart @@ -0,0 +1,119 @@ +import 'package:bitree/agenda/deadline_source.dart'; +import 'package:bitree/agenda/model.dart'; +import 'package:bitree/agenda/ui/timeline_entry.dart'; +import 'package:bitree/ui/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/system_palette.dart'; + +void main() { + for (final brightness in Brightness.values) { + for (final system in [false, true]) { + testWidgets( + 'Overdue DDLs keep source colours and only turn the right border red in $brightness, system=$system', + (tester) async { + final theme = bitreeTheme( + brightness, + systemPalette: system ? testSystemPalette() : null, + ); + final due = DateTime.utc(2026, 11, 17, 13); + final entry = AgendaEntry( + id: 'eclass', + title: '第五章作业', + kind: AgendaKind.deadline, + startsAt: due, + source: DeadlineSource.eclass, + ); + Future show(AgendaEntry value, DateTime now) async { + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: SizedBox( + width: 180, + height: 120, + child: AgendaTimelineEntry( + entry: value, + now: now, + barWidth: 180, + deadlineWidth: 180, + onTap: () {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + final bar = find.byKey( + const ValueKey(('agenda-timeline-bar', 'eclass')), + ); + + List fill() { + final ink = tester.widget( + find.descendant(of: bar, matching: find.byType(Ink)), + ); + final decoration = ink.decoration! as BoxDecoration; + return (decoration.gradient! as LinearGradient).colors; + } + + Border border() { + final foreground = find.descendant( + of: bar, + matching: find.byWidgetPredicate( + (widget) => + widget is DecoratedBox && + widget.position == DecorationPosition.foreground, + ), + ); + final box = tester.widget(foreground); + return (box.decoration as BoxDecoration).border! as Border; + } + + Color titleColor() => + tester.widget(find.text(entry.title)).style!.color!; + Color captionColor() => + tester.widget(find.text('13:00 截止')).style!.color!; + + await show(entry, due.subtract(const Duration(minutes: 1))); + final originalFill = fill(); + final originalText = titleColor(); + final originalCaption = captionColor(); + final originalBorder = border().right; + + await show(entry, due.add(const Duration(minutes: 1))); + expect(fill(), originalFill); + expect(titleColor(), originalText); + expect(captionColor(), originalCaption); + expect( + border().right, + BorderSide(color: theme.colorScheme.error, width: 3), + ); + expect(border().left, BorderSide.none); + expect(border().top, BorderSide.none); + expect(border().bottom, BorderSide.none); + expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); + + await show( + entry.copyWith(done: true), + due.add(const Duration(minutes: 1)), + ); + expect(fill().last, originalFill.last.withValues(alpha: 0.5)); + expect(titleColor(), originalText.withValues(alpha: 0.5)); + expect( + border().right.color, + originalBorder.color.withValues(alpha: 0.5), + ); + expect(border().right.width, originalBorder.width); + expect(find.byIcon(Icons.warning_amber_rounded), findsNothing); + expect(find.byIcon(Icons.check_circle_outline), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + } + } +} diff --git a/test/appearance_settings_test.dart b/test/appearance_settings_test.dart new file mode 100644 index 0000000..f9a9959 --- /dev/null +++ b/test/appearance_settings_test.dart @@ -0,0 +1,122 @@ +import 'package:bitree/app/ui/appearance_screen.dart'; +import 'package:bitree/app/ui/settings_screen.dart'; +import 'package:bitree/main.dart'; +import 'package:bitree/ui/app_menu_anchor.dart'; +import 'package:bitree/ui/course_palette.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/test_app.dart'; + +void main() { + testWidgets( + 'Appearance subpage exposes all appearance choices without developer mode', + (tester) async { + tester.view.physicalSize = const Size(320, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final controller = TestAppController(); + await controller.loadCoursePalettes(); + await tester.pumpWidget(BitreeApp(controller: controller)); + await tester.tap( + find.descendant( + of: find.byType(NavigationBar), + matching: find.text('设置'), + ), + ); + await tester.pumpAndSettle(); + expect(controller.developerMode, isFalse); + expect(find.byType(SegmentedButton), findsNothing); + expect(find.text('深色模式调色板'), findsNothing); + await tester.tap(find.text('外观')); + await tester.pumpAndSettle(); + expect(find.byType(AppearanceScreen), findsOneWidget); + expect(find.text('浅色模式调色板'), findsOneWidget); + expect(find.text('深色模式调色板'), findsOneWidget); + expect(find.text('导入调色板'), findsOneWidget); + expect(find.text('默认'), findsNWidgets(2)); + + for (final (label, mode) in [ + ('浅色', ThemeMode.light), + ('跟随系统', ThemeMode.system), + ('深色', ThemeMode.dark), + ]) { + await tester.tap( + find.descendant( + of: find.byType(SegmentedButton), + matching: find.text(label), + ), + ); + await tester.pumpAndSettle(); + expect(controller.themeMode, mode); + expect( + tester + .widget>( + find.byType(SegmentedButton), + ) + .selected, + {mode}, + ); + } + + final systemPaletteSwitch = find.widgetWithText( + SwitchListTile, + '小组件使用系统调色板', + ); + await tester.ensureVisible(systemPaletteSwitch); + expect(tester.widget(systemPaletteSwitch).value, isFalse); + await tester.tap(systemPaletteSwitch); + await tester.pumpAndSettle(); + expect(controller.widgetUseSystemPalette, isTrue); + expect(tester.widget(systemPaletteSwitch).value, isTrue); + final globalPaletteSwitch = find.widgetWithText( + SwitchListTile, + '全局系统调色板(实验性)', + ); + await tester.ensureVisible(globalPaletteSwitch); + expect(tester.widget(globalPaletteSwitch).value, isFalse); + await tester.tap(globalPaletteSwitch); + await tester.pumpAndSettle(); + expect(controller.globalUseSystemPalette, isTrue); + expect(controller.widgetUseSystemPalette, isTrue); + expect(tester.widget(globalPaletteSwitch).value, isTrue); + // Missing platform colours keep the existing app appearance. + expect(controller.systemPalette, isNull); + await tester.ensureVisible(find.text('深色模式调色板')); + await tester.tap(find.text('深色模式调色板')); + await tester.pumpAndSettle(); + for (final label in ['默认', 'Mocha', 'Macchiato', 'Frappé']) { + expect(find.widgetWithText(AppMenuItemButton, label), findsOneWidget); + } + await tester.tap(find.widgetWithText(AppMenuItemButton, 'Mocha')); + await tester.pumpAndSettle(); + expect(find.text('Mocha'), findsOneWidget); + expect(controller.paletteIdFor(Brightness.dark), 'mocha'); + expect(controller.widgetUseSystemPalette, isTrue); + final theme = Theme.of(tester.element(find.byType(AppearanceScreen))); + expect( + theme.extension()!.palette, + same(controller.paletteFor(Brightness.dark)), + ); + expect(theme.colorScheme.surface, const Color(0xFF171717)); + expect(theme.colorScheme.surfaceContainer, const Color(0xFF282828)); + + await tester.pageBack(); + await tester.pumpAndSettle(); + expect(find.byType(SettingsScreen), findsOneWidget); + expect(find.byType(AppearanceScreen), findsNothing); + expect(find.text('深色模式调色板'), findsNothing); + expect(find.widgetWithText(ListTile, '深色'), findsOneWidget); + await tester.tap(find.text('外观')); + await tester.pumpAndSettle(); + expect(find.text('Mocha'), findsOneWidget); + expect(tester.widget(systemPaletteSwitch).value, isTrue); + expect(tester.widget(globalPaletteSwitch).value, isTrue); + expect(controller.developerMode, isFalse); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + }, + ); +} diff --git a/test/building_palette_test.dart b/test/building_palette_test.dart index d9df548..c8af2b3 100644 --- a/test/building_palette_test.dart +++ b/test/building_palette_test.dart @@ -4,10 +4,13 @@ import 'dart:math' as math; import 'package:bitree/schedule/building_palette.dart'; import 'package:bitree/schedule/model.dart'; +import 'package:bitree/ui/course_palette.dart'; import 'package:bitree/ui/theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'support/course_palettes.dart'; + void main() { test('Building aliases, wing codes, room numbers and full-width text', () { const rooms = { @@ -60,10 +63,13 @@ void main() { }, ); - final table = jsonDecode( - File('android/app/src/main/assets/building_palette.json') - .readAsStringSync(), - ) as Map; + final table = + jsonDecode( + File( + 'android/app/src/main/assets/building_palette.json', + ).readAsStringSync(), + ) + as Map; final buildings = (table['buildings'] as List).cast>(); test('Android and Flutter share every alias and both theme palettes', () { @@ -167,6 +173,128 @@ void main() { } }); + testWidgets( + 'Optional palettes preserve surfaces and export matching opaque widget course colours', + (tester) async { + for (final palette in [ + null, + ...testPalettes().values, + lightTestPalette(), + ]) { + final paletteName = palette?.name; + final widgetColors = palette?.widgetColors ?? >{}; + if (palette == null) { + expect(widgetColors, isEmpty); + } else { + expect( + widgetColors.keys, + unorderedEquals([ + ...CampusArea.values.map((area) => area.name), + 'neutral', + 'surface', + ]), + ); + expect(widgetColors['surface']!.first, switch (palette.name) { + 'Mocha' => 0xFF1E1E2E, + 'Macchiato' => 0xFF24273A, + 'Frappé' => 0xFF303446, + _ => 0xFFF8F8F8, + }); + } + for (final brightness in Brightness.values) { + final originalTheme = bitreeTheme(brightness); + final theme = bitreeTheme(brightness, coursePalette: palette); + expect(theme.colorScheme, originalTheme.colorScheme); + expect( + theme.scaffoldBackgroundColor, + originalTheme.scaffoldBackgroundColor, + ); + await tester.pumpWidget( + MaterialApp( + key: ValueKey((palette, brightness)), + theme: theme, + home: Builder( + builder: (context) { + for (final building in [ + ...buildings.map( + (row) => buildingPaletteNamed(row['name'] as String)!, + ), + unknownBuildingPalette, + ]) { + final course = Course( + id: 'course', + name: '测试课程', + weekday: 1, + startSection: 1, + endSection: 2, + weeks: [1], + room: building.name, + ); + final colors = courseColors(context, course); + if (brightness == palette?.brightness) { + expect( + widgetColors[building.area?.name ?? 'neutral'], + [colors.fill.toARGB32(), colors.text.toARGB32()], + reason: '${building.name} uses the same widget colours', + ); + } + if (brightness == Brightness.light && + palette?.brightness != brightness) { + expect(colors.fill, Color(building.light.fill)); + expect(colors.text, Color(building.light.text)); + expect(colors.accent, Color(building.light.accent)); + } else if (palette?.brightness != brightness) { + expect( + colors.fill, + Color.lerp( + Color(building.dark.fill), + Color(building.dark.accent), + 0.24, + ), + ); + expect(colors.text, Color(building.dark.text)); + expect(colors.accent, Color(building.dark.accent)); + } else { + expect( + colors.text, + Color(switch (paletteName) { + 'Mocha' => 0xFFCDD6F4, + 'Macchiato' => 0xFFCAD3F5, + 'Frappé' => 0xFFC6D0F5, + _ => 0xFF242424, + }), + ); + if (building.area == CampusArea.wencui) { + expect( + colors.accent, + Color(switch (paletteName) { + 'Mocha' => 0xFFA6E3A1, + 'Macchiato' => 0xFFA6DA95, + 'Frappé' => 0xFFA6D189, + _ => 0xFF336633, + }), + ); + } + } + for (final (foreground, background) in [ + (colors.text, colors.fill), + (colors.secondaryText, colors.fill), + (colors.text, colors.elapsedFill), + ]) { + expect(foreground.a, 1); + expect(background.a, 1); + } + } + return const SizedBox.shrink(); + }, + ), + ), + ); + } + } + }, + ); + testWidgets( 'Course colours stay opaque and readable before and during class', (tester) async { @@ -207,9 +335,9 @@ void main() { ); } if (brightness == Brightness.dark) { - final column = Theme.of(context) - .colorScheme - .surfaceContainer; + final column = Theme.of( + context, + ).colorScheme.surfaceContainer; expect( (colors.fill.computeLuminance() + 0.05) / (column.computeLuminance() + 0.05), diff --git a/test/calendar_block_test.dart b/test/calendar_block_test.dart new file mode 100644 index 0000000..de5bda1 --- /dev/null +++ b/test/calendar_block_test.dart @@ -0,0 +1,220 @@ +import 'dart:math' as math; + +import 'package:bitree/agenda/calendar.dart'; +import 'package:bitree/agenda/deadline_source.dart'; +import 'package:bitree/agenda/model.dart'; +import 'package:bitree/agenda/ui/calendar_block.dart'; +import 'package:bitree/agenda/ui/timeline_colors.dart'; +import 'package:bitree/schedule/building_palette.dart'; +import 'package:bitree/ui/course_palette.dart'; +import 'package:bitree/ui/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/course_palettes.dart'; +import 'support/system_palette.dart'; +import 'support/test_app.dart'; + +double _contrast(Color a, Color b) { + final first = a.computeLuminance(); + final second = b.computeLuminance(); + final lighter = math.max(first, second); + final darker = math.min(first, second); + return (lighter + 0.05) / (darker + 0.05); +} + +void main() { + final palettes = testPalettes(); + test('Agenda colours mix Base with Lavender and share Text', () { + for (final (id, base, text, accent) in [ + ('mocha', 0xFF1E1E2E, 0xFFCDD6F4, 0xFFB4BEFE), + ('macchiato', 0xFF24273A, 0xFFCAD3F5, 0xFFB7BDF8), + ('frappe', 0xFF303446, 0xFFC6D0F5, 0xFFBABBF1), + ]) { + expect(palettes[id]!.agendaColors, ( + fill: Color.lerp(Color(base), Color(accent), 0.24)!.toARGB32(), + text: text, + accent: accent, + )); + } + }); + + for (final brightness in Brightness.values) { + for (final system in [false, true]) { + for (final palette in [ + null, + ...palettes.values, + lightTestPalette(), + ]) { + testWidgets( + 'Home and timeline event blocks stay visible with ${palette?.name ?? 'default'}, system=$system, $brightness', + (tester) async { + final systemPalette = system ? testSystemPalette() : null; + final theme = bitreeTheme( + brightness, + coursePalette: palette, + systemPalette: systemPalette, + ); + final original = bitreeTheme( + brightness, + systemPalette: systemPalette, + ); + expect(theme.colorScheme, original.colorScheme); + expect( + theme.scaffoldBackgroundColor, + original.scaffoldBackgroundColor, + ); + expect(theme.textTheme, original.textTheme); + final paletteColors = palette?.agendaColors; + final cat = brightness == palette?.brightness && !system + ? paletteColors + : null; + final scheme = theme.colorScheme; + final controller = TestAppController(); + final startsAt = DateTime.utc(2026, 11, 17, 10); + final event = AgendaEntry( + id: 'manual', + title: '手动日程', + kind: AgendaKind.event, + startsAt: startsAt, + endsAt: startsAt.add(const Duration(hours: 1)), + ); + final exam = AgendaEntry( + id: 'exam', + title: '期末考试', + kind: AgendaKind.exam, + startsAt: startsAt.add(const Duration(hours: 2)), + endsAt: startsAt.add(const Duration(hours: 3)), + ); + + Future show(AgendaEntry entry) async { + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: SizedBox( + width: 120, + height: 120, + child: CalendarBlockTile( + items: [CalendarItem.agenda(entry)], + controller: controller, + isToday: false, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + Material block() => tester.widget( + find + .descendant( + of: find.byType(CalendarBlockTile), + matching: find.byType(Material), + ) + .first, + ); + BorderSide side() => + (block().shape! as RoundedRectangleBorder).side; + + await show(event); + final material = block(); + final title = tester.widget(find.text(event.title)); + final fill = cat == null + ? scheme.primaryContainer + : Color(cat.fill); + final text = cat == null + ? scheme.onPrimaryContainer + : Color(cat.text); + final accent = cat == null ? scheme.primary : Color(cat.accent); + expect(material.color, fill); + expect(title.style!.color, text); + expect(side().color, accent); + final context = tester.element(find.byType(CalendarBlockTile)); + final timeline = agendaTimelineColors(context, event, startsAt); + expect(timeline, (fill: fill, text: text, accent: accent)); + if (cat != null) { + final finished = agendaTimelineColors( + context, + event, + startsAt.add(const Duration(hours: 2)), + ); + expect(finished.fill, fill.withValues(alpha: 0.55)); + final deadline = AgendaEntry( + id: 'manual-ddl', + title: '自建 DDL', + kind: AgendaKind.deadline, + startsAt: startsAt, + ); + expect( + agendaTimelineColors(context, deadline, startsAt), + timeline, + ); + expect( + agendaTimelineColors( + context, + deadline.copyWith(done: true), + startsAt, + ), + ( + fill: fill.withValues(alpha: 0.5), + text: text.withValues(alpha: 0.5), + accent: accent.withValues(alpha: 0.5), + ), + ); + } + for (final (source, building) in [ + (DeadlineSource.lexue, '工程训练中心'), + (DeadlineSource.eclass, '文化体育中心'), + ]) { + final sourcePalette = buildingPaletteNamed(building)!; + final values = brightness == Brightness.dark + ? sourcePalette.dark + : sourcePalette.light; + final deadline = AgendaEntry( + id: source.name, + title: '平台作业', + kind: AgendaKind.deadline, + startsAt: startsAt, + source: source, + ); + expect(agendaTimelineColors(context, deadline, startsAt), ( + fill: Color(values.fill), + text: Color(values.text), + accent: Color(values.accent), + )); + } + expect(side().width, 1); + // The fill is deliberately subtle in light mode; the outline and text + // must carry visibility under both the app and system palettes. + expect(_contrast(side().color, material.color!), greaterThan(3)); + expect(_contrast(side().color, scheme.surface), greaterThan(3)); + expect( + _contrast(title.style!.color!, material.color!), + greaterThan(4.5), + ); + + await show(exam); + final examMaterial = block(); + final examTitle = tester.widget(find.text(exam.title)); + expect(examMaterial.color, scheme.errorContainer); + expect(examTitle.style!.color, scheme.onErrorContainer); + expect(side().color, scheme.error); + expect( + _contrast(side().color, examMaterial.color!), + greaterThan(3), + ); + expect( + _contrast(examTitle.style!.color!, examMaterial.color!), + greaterThan(4.5), + ); + expect(tester.takeException(), isNull); + }, + ); + } + } + } +} diff --git a/test/course_palette_test.dart b/test/course_palette_test.dart new file mode 100644 index 0000000..21bf58a --- /dev/null +++ b/test/course_palette_test.dart @@ -0,0 +1,243 @@ +import 'dart:convert'; + +import 'package:bitree/app/ui/palette_settings.dart'; +import 'package:bitree/ui/course_palette.dart'; +import 'package:bitree/ui/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/course_palettes.dart'; +import 'support/test_app.dart'; + +class _FailingPalettePreferences extends MemoryPreferences { + _FailingPalettePreferences(this.shouldFail); + + final bool Function() shouldFail; + + @override + Future setString(String key, String value) async { + if (shouldFail() && key == 'coursePalettes') { + throw StateError('Storage unavailable'); + } + await super.setString(key, value); + } +} + +void main() { + final builtins = testPalettes(); + final mocha = builtins['mocha']!; + + test('Flat JSON round-trips every opaque colour and brightness', () { + for (final palette in [...builtins.values, lightTestPalette()]) { + final restored = CoursePalette.parse(jsonEncode(palette.toJson())); + expect(restored.name, palette.name); + expect(restored.brightness, palette.brightness); + expect(restored.toJson(), palette.toJson()); + expect(restored.widgetColors, palette.widgetColors); + expect(restored.agendaColors, palette.agendaColors); + } + expect(mocha.colorsFor(null).accent, 0xFFBAC2DE); + expect(mocha.agendaColors.accent, 0xFFB4BEFE); + }); + + test( + 'Missing fields, invalid types, alpha and malformed JSON are rejected', + () { + for (final key in ['name', 'brightness', ...CoursePalette.colorKeys]) { + final missing = {...mocha.toJson()}..remove(key); + expect( + () => CoursePalette.fromJson(missing), + throwsFormatException, + reason: 'missing $key', + ); + expect( + () => CoursePalette.fromJson({...mocha.toJson(), key: 42}), + throwsFormatException, + reason: 'non-string $key', + ); + } + for (final name in ['', ' ']) { + expect( + () => CoursePalette.fromJson({...mocha.toJson(), 'name': name}), + throwsFormatException, + ); + } + for (final value in ['system', 'Dark', '', null]) { + expect( + () => + CoursePalette.fromJson({...mocha.toJson(), 'brightness': value}), + throwsFormatException, + ); + } + for (final value in [ + '#AA112233', + '#123', + '112233', + '#GG2233', + '#123456\n', + ]) { + expect( + () => CoursePalette.fromJson({...mocha.toJson(), 'agenda': value}), + throwsFormatException, + ); + } + for (final source in ['{', '[]', 'null', '"palette"']) { + expect(() => CoursePalette.parse(source), throwsFormatException); + } + }, + ); + + testWidgets('Bundled Cat JSON loads through the production asset bundle', ( + tester, + ) async { + final loaded = await CoursePalette.loadBuiltins(); + expect(loaded.keys, builtins.keys); + for (final id in loaded.keys) { + expect(loaded[id]!.toJson(), builtins[id]!.toJson()); + } + }); + + test( + 'Imports persist their contents and select light and dark independently', + () async { + final preferences = MemoryPreferences(); + final first = TestAppController(preferences: preferences); + await first.initialize(); + await first.setCoursePalette(Brightness.dark, 'mocha'); + await first.importCoursePalette(jsonEncode(lightTestPalette().toJson())); + final lightId = first.paletteIdFor(Brightness.light)!; + expect(first.paletteIdFor(Brightness.dark), 'mocha'); + expect(first.themeMode, ThemeMode.system); + expect(first.palettesFor(Brightness.light).keys, [lightId]); + expect(first.palettesFor(Brightness.dark).keys, builtins.keys); + + await first.importCoursePalette( + jsonEncode({...mocha.toJson(), 'name': '自定义深色'}), + ); + final darkId = first.paletteIdFor(Brightness.dark)!; + final second = TestAppController(preferences: preferences); + await second.initialize(); + expect(second.paletteIdFor(Brightness.light), lightId); + expect(second.paletteIdFor(Brightness.dark), darkId); + expect( + second.paletteFor(Brightness.light)!.toJson(), + lightTestPalette().toJson(), + ); + expect(second.paletteFor(Brightness.dark)!.name, '自定义深色'); + expect(second.customPalettes, hasLength(2)); + expect(second.globalUseSystemPalette, isFalse); + expect(second.widgetUseSystemPalette, isFalse); + }, + ); + + test( + 'Invalid imports and failed saves preserve the current selection and catalog', + () async { + var fail = false; + final preferences = _FailingPalettePreferences(() => fail); + final controller = TestAppController(preferences: preferences); + await controller.setCoursePalette(Brightness.dark, 'mocha'); + final saved = await preferences.getString('coursePalettes'); + await expectLater( + controller.importCoursePalette('{"name":"broken"}'), + throwsFormatException, + ); + fail = true; + await expectLater( + controller.importCoursePalette(jsonEncode(lightTestPalette().toJson())), + throwsStateError, + ); + expect(controller.paletteIdFor(Brightness.dark), 'mocha'); + expect(controller.paletteFor(Brightness.light), isNull); + expect(controller.customPalettes, isEmpty); + expect(await preferences.getString('coursePalettes'), saved); + fail = false; + // A failed import must not poison subsequent writes. + await controller.importCoursePalette( + jsonEncode(lightTestPalette().toJson()), + ); + expect(controller.customPalettes, hasLength(1)); + }, + ); + + test( + 'Deletion restores only the selected mode and persists removal', + () async { + final preferences = MemoryPreferences(); + final first = TestAppController(preferences: preferences); + await Future.wait([ + first.importCoursePalette(jsonEncode(lightTestPalette().toJson())), + first.importCoursePalette(jsonEncode(mocha.toJson())), + ]); + final lightId = first.paletteIdFor(Brightness.light)!; + final darkId = first.paletteIdFor(Brightness.dark)!; + await first.deleteCoursePalette(lightId); + expect(first.paletteFor(Brightness.light), isNull); + expect(first.paletteIdFor(Brightness.dark), darkId); + await first.setCoursePalette(Brightness.dark, 'frappe'); + await first.deleteCoursePalette(darkId); + expect(first.paletteIdFor(Brightness.dark), 'frappe'); + await first.deleteCoursePalette('frappe'); + expect(first.palettesFor(Brightness.dark).keys, builtins.keys); + + final second = TestAppController(preferences: preferences); + await second.initialize(); + expect(second.customPalettes, isEmpty); + expect(second.paletteFor(Brightness.light), isNull); + expect(second.paletteIdFor(Brightness.dark), 'frappe'); + }, + ); + + test( + 'Legacy Cat selections migrate and brightness mismatches are rejected', + () async { + for (final id in CoursePalette.builtinIds) { + final preferences = MemoryPreferences(); + await preferences.setString('darkCoursePalette', id); + final controller = TestAppController(preferences: preferences); + await controller.initialize(); + expect(controller.paletteIdFor(Brightness.dark), id); + expect(controller.paletteFor(Brightness.light), isNull); + await expectLater( + controller.setCoursePalette(Brightness.light, id), + throwsFormatException, + ); + expect(controller.paletteIdFor(Brightness.dark), id); + await controller.setCoursePalette(Brightness.dark, null); + final restarted = TestAppController(preferences: preferences); + await restarted.initialize(); + expect(restarted.paletteFor(Brightness.dark), isNull); + } + }, + ); + + testWidgets( + 'Only imported palettes have delete buttons and deletion updates the menu', + (tester) async { + final controller = TestAppController(); + await controller.importCoursePalette( + jsonEncode(lightTestPalette().toJson()), + ); + await tester.pumpWidget( + MaterialApp( + theme: bitreeTheme(Brightness.light), + home: Scaffold( + body: ListenableBuilder( + listenable: controller, + builder: (context, _) => + ListView(children: [PaletteSettings(controller: controller)]), + ), + ), + ), + ); + expect(find.byIcon(Icons.delete_outline_rounded), findsOneWidget); + expect(find.byTooltip('删除 Mocha'), findsNothing); + await tester.tap(find.byTooltip('删除 自定义浅色')); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.delete_outline_rounded), findsNothing); + expect(controller.paletteFor(Brightness.light), isNull); + expect(find.text('默认'), findsNWidgets(2)); + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/test/developer_settings_test.dart b/test/developer_settings_test.dart new file mode 100644 index 0000000..7690dc7 --- /dev/null +++ b/test/developer_settings_test.dart @@ -0,0 +1,153 @@ +import 'package:bitree/app/ui/settings_screen.dart'; +import 'package:bitree/main.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import 'support/test_app.dart'; + +void main() { + test( + 'Palette persists while developer mode resets for each app session', + () async { + final preferences = MemoryPreferences(); + final first = TestAppController(preferences: preferences); + await first.initialize(); + expect(first.developerMode, isFalse); + expect(first.paletteIdFor(Brightness.dark), isNull); + first.unlockDeveloperMode(); + expect(first.developerMode, isTrue); + final writes = [ + first.setCoursePalette(Brightness.dark, 'mocha'), + first.setCoursePalette(Brightness.dark, 'macchiato'), + first.setCoursePalette(Brightness.dark, 'frappe'), + ]; + await Future.wait(writes); + + final second = TestAppController(preferences: preferences); + await second.initialize(); + expect(second.developerMode, isFalse); + expect(second.paletteIdFor(Brightness.dark), 'frappe'); + await second.setCoursePalette(Brightness.dark, null); + + final third = TestAppController(preferences: preferences); + await third.initialize(); + expect(third.paletteFor(Brightness.dark), isNull); + }, + ); + + test( + 'Widget system course palette defaults off and persists independently of developer mode', + () async { + final preferences = MemoryPreferences(); + final first = TestAppController(preferences: preferences); + await first.initialize(); + expect(first.widgetUseSystemPalette, isFalse); + await Future.wait([ + first.setWidgetUseSystemPalette(true), + first.setWidgetUseSystemPalette(false), + first.setWidgetUseSystemPalette(true), + first.setCoursePalette(Brightness.dark, 'mocha'), + ]); + + final second = TestAppController(preferences: preferences); + await second.initialize(); + expect(second.widgetUseSystemPalette, isTrue); + expect(second.paletteIdFor(Brightness.dark), 'mocha'); + expect(second.developerMode, isFalse); + await second.setCoursePalette(Brightness.dark, null); + expect(second.widgetUseSystemPalette, isTrue); + await second.setWidgetUseSystemPalette(false); + + final third = TestAppController(preferences: preferences); + await third.initialize(); + expect(third.widgetUseSystemPalette, isFalse); + expect(third.paletteFor(Brightness.dark), isNull); + }, + ); + + test('Unknown saved palette falls back to the current app colours', () async { + final preferences = MemoryPreferences(); + await preferences.setString('darkCoursePalette', 'retired-palette'); + final controller = TestAppController(preferences: preferences); + await controller.initialize(); + expect(controller.paletteFor(Brightness.dark), isNull); + expect(controller.developerMode, isFalse); + }); + + testWidgets( + 'Five consecutive About icon taps keep developer mode without revealing options', + (tester) async { + final semantics = tester.ensureSemantics(); + addTearDown(semantics.dispose); + PackageInfo.setMockInitialValues( + appName: 'BITree', + packageName: 'app.bitree', + version: '0.3.0', + buildNumber: '1', + buildSignature: '', + ); + tester.view.physicalSize = const Size(320, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final controller = TestAppController(); + await controller.loadCoursePalettes(); + await controller.setTheme(ThemeMode.dark); + await tester.pumpWidget(BitreeApp(controller: controller)); + await tester.tap( + find.descendant( + of: find.byType(NavigationBar), + matching: find.text('设置'), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('更多设置'), findsNothing); + expect(find.text('小组件使用系统调色板'), findsNothing); + expect(find.text('全局系统调色板(实验性)'), findsNothing); + final scrollable = find + .descendant( + of: find.byType(SettingsScreen), + matching: find.byType(Scrollable), + ) + .first; + await tester.scrollUntilVisible( + find.text('关于 BITree'), + 200, + scrollable: scrollable, + ); + await tester.tap(find.text('关于 BITree')); + await tester.pumpAndSettle(); + final icon = find.bySemanticsLabel('BITree 图标'); + for (var i = 0; i < 4; i++) { + await tester.tap(icon); + await tester.pump(const Duration(milliseconds: 100)); + } + expect(controller.developerMode, isFalse); + // A pause starts a fresh sequence. + await tester.pump(const Duration(seconds: 3)); + for (var i = 0; i < 4; i++) { + await tester.tap(icon); + await tester.pump(const Duration(milliseconds: 100)); + } + expect(controller.developerMode, isFalse); + await tester.tap(icon); + await tester.pumpAndSettle(); + expect(controller.developerMode, isTrue); + expect(find.byType(AlertDialog), findsNothing); + expect(find.text('更多设置'), findsNothing); + expect(find.text('深色模式调色板'), findsNothing); + expect(find.text('小组件使用系统调色板'), findsNothing); + expect(find.text('全局系统调色板(实验性)'), findsNothing); + await tester.scrollUntilVisible( + find.text('外观'), + -200, + scrollable: scrollable, + ); + expect(find.text('外观'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + }, + ); +} diff --git a/test/support/course_palettes.dart b/test/support/course_palettes.dart new file mode 100644 index 0000000..3a01136 --- /dev/null +++ b/test/support/course_palettes.dart @@ -0,0 +1,24 @@ +import 'dart:io'; + +import 'package:bitree/ui/course_palette.dart'; + +Map testPalettes() => { + for (final id in CoursePalette.builtinIds) + id: CoursePalette.parse( + File('assets/palettes/$id.json').readAsStringSync(), + ), +}; + +CoursePalette lightTestPalette() => CoursePalette.fromJson({ + 'name': '自定义浅色', + 'brightness': 'light', + 'base': '#F8F8F8', + 'text': '#242424', + 'northTeaching': '#245390', + 'wencui': '#336633', + 'southScience': '#765200', + 'eastFacilities': '#614A7A', + 'southEngineering': '#884C24', + 'neutral': '#555555', + 'agenda': '#614A7A', +}); diff --git a/test/support/system_palette.dart b/test/support/system_palette.dart new file mode 100644 index 0000000..411cb11 --- /dev/null +++ b/test/support/system_palette.dart @@ -0,0 +1,46 @@ +import 'package:bitree/core/platform/system_palette.dart'; +import 'package:bitree/ui/system_palette.dart'; +import 'package:flutter/material.dart'; + +Map> systemPaletteValues({double hue = 150}) => { + for (final (name, offset, saturation) in [ + ('accent1', 0, 0.5), + ('accent2', 30, 0.25), + ('accent3', 60, 0.4), + ('neutral1', 0, 0.05), + ('neutral2', 0, 0.1), + ]) + name: [ + for (final tone in [100, 99, 95, 90, 80, 70, 60, 50, 40, 30, 20, 10, 0]) + HSLColor.fromAHSL( + 1, + (hue + offset) % 360, + saturation, + tone / 100, + ).toColor().toARGB32().toSigned(32), + ], +}; + +SystemPalette testSystemPalette({double hue = 150}) => + SystemPalette.fromPlatform(systemPaletteValues(hue: hue))!; + +class MemorySystemPaletteSource extends SystemPaletteSource { + MemorySystemPaletteSource({this.palette, this.loader}); + + SystemPalette? palette; + Future Function()? loader; + VoidCallback? onChanged; + int reads = 0; + + @override + Future load() async { + reads++; + return loader == null ? palette : await loader!(); + } + + @override + void watch(VoidCallback onChanged) => this.onChanged = onChanged; + + @override + void dispose() => onChanged = null; +} diff --git a/test/support/test_app.dart b/test/support/test_app.dart index 1252ee4..a02e6b6 100644 --- a/test/support/test_app.dart +++ b/test/support/test_app.dart @@ -16,6 +16,8 @@ import 'package:cookie_jar/cookie_jar.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'course_palettes.dart'; + class MemoryScheduleStore extends ScheduleStore { MemoryScheduleStore({ List schedules = const [], @@ -68,18 +70,22 @@ class MemoryAcademicStore extends AcademicStore { } class MemoryPreferences extends Fake implements SharedPreferencesAsync { - MemoryPreferences([Map values = const {}]) + MemoryPreferences([Map values = const {}]) : _values = Map.of(values); - final Map _values; + final Map _values; - String? value(String key) => _values[key]; + String? value(String key) => _values[key] as String?; @override - Future getString(String key) async => _values[key]; + Future getString(String key) async => _values[key] as String?; @override Future setString(String key, String value) async => _values[key] = value; + @override + Future getBool(String key) async => _values[key] as bool?; + @override + Future setBool(String key, bool value) async => _values[key] = value; } class MemorySchoolStorage extends SchoolStorage { @@ -126,7 +132,9 @@ class TestAppController extends AppController { MoocStorage? moocStorage, SchoolService? school, SharedPreferencesAsync? preferences, + super.systemPaletteSource, }) : super( + coursePaletteLoader: () async => testPalettes(), store: MemoryScheduleStore(schedules: schedules, selected: schedule), school: school ?? SchoolService(), schoolStorage: schoolStorage ?? MemorySchoolStorage(), diff --git a/test/system_palette_test.dart b/test/system_palette_test.dart new file mode 100644 index 0000000..8ee48d6 --- /dev/null +++ b/test/system_palette_test.dart @@ -0,0 +1,290 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:bitree/agenda/deadline_source.dart'; +import 'package:bitree/agenda/model.dart'; +import 'package:bitree/agenda/ui/timeline_colors.dart'; +import 'package:bitree/app/ui/settings_screen.dart'; +import 'package:bitree/main.dart'; +import 'package:bitree/schedule/building_palette.dart'; +import 'package:bitree/schedule/model.dart'; +import 'package:bitree/ui/course_palette.dart'; +import 'package:bitree/ui/system_palette.dart'; +import 'package:bitree/ui/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/course_palettes.dart'; +import 'support/system_palette.dart'; +import 'support/test_app.dart'; + +void main() { + test( + 'Platform palettes accept signed Android colours and reject incomplete data', + () { + final values = systemPaletteValues(); + final palette = SystemPalette.fromPlatform(values)!; + expect( + palette.colorScheme(Brightness.light).primary, + Color(values['accent1']![8]), + ); + expect( + palette.colorScheme(Brightness.dark).primary, + Color(values['accent1']![4]), + ); + expect(SystemPalette.fromPlatform(null), isNull); + expect( + SystemPalette.fromPlatform({...values}..remove('neutral2')), + isNull, + ); + expect( + SystemPalette.fromPlatform({ + ...values, + 'accent1': [1, 2], + }), + isNull, + ); + expect( + SystemPalette.fromPlatform({ + ...values, + 'accent1': List.filled(13, 'invalid'), + }), + isNull, + ); + }, + ); + + test( + 'Global palette defaults off and persists independently of other appearance choices', + () async { + final preferences = MemoryPreferences(); + final source = MemorySystemPaletteSource(palette: testSystemPalette()); + final first = TestAppController( + preferences: preferences, + systemPaletteSource: source, + ); + await first.initialize(); + expect(first.globalUseSystemPalette, isFalse); + expect(first.systemPalette, isNull); + expect(source.reads, 0); + await first.setCoursePalette(Brightness.dark, 'mocha'); + await Future.wait([ + first.setGlobalUseSystemPalette(true), + first.setGlobalUseSystemPalette(false), + first.setGlobalUseSystemPalette(true), + ]); + expect(first.systemPalette, same(source.palette)); + expect(first.widgetUseSystemPalette, isFalse); + + final second = TestAppController( + preferences: preferences, + systemPaletteSource: MemorySystemPaletteSource(palette: source.palette), + ); + await second.initialize(); + expect(second.globalUseSystemPalette, isTrue); + expect(second.systemPalette, same(source.palette)); + expect(second.paletteIdFor(Brightness.dark), 'mocha'); + expect(second.developerMode, isFalse); + expect(second.widgetUseSystemPalette, isFalse); + await second.setGlobalUseSystemPalette(false); + expect(second.systemPalette, isNull); + expect(second.paletteIdFor(Brightness.dark), 'mocha'); + + final third = TestAppController(preferences: preferences); + await third.initialize(); + expect(third.globalUseSystemPalette, isFalse); + }, + ); + + test( + 'Late platform reads cannot replace a newer palette or reactivate a disabled palette', + () async { + final requests = >[]; + final source = MemorySystemPaletteSource( + loader: () { + final request = Completer(); + requests.add(request); + return request.future; + }, + ); + final controller = TestAppController(systemPaletteSource: source); + await controller.initialize(); + final first = controller.setGlobalUseSystemPalette(true); + await controller.setGlobalUseSystemPalette(false); + final second = controller.setGlobalUseSystemPalette(true); + final latest = testSystemPalette(hue: 250); + requests[1].complete(latest); + await second; + requests[0].complete(testSystemPalette()); + await first; + expect(controller.systemPalette, same(latest)); + final pending = controller.setGlobalUseSystemPalette(true); + await controller.setGlobalUseSystemPalette(false); + requests.last.complete(testSystemPalette()); + await pending; + expect(controller.systemPalette, isNull); + }, + ); + + test( + 'System changes refresh colours only while enabled; unsupported systems use the app theme', + () async { + final source = MemorySystemPaletteSource(); + final controller = TestAppController(systemPaletteSource: source); + await controller.initialize(); + await controller.setGlobalUseSystemPalette(true); + expect(controller.globalUseSystemPalette, isTrue); + expect(controller.systemPalette, isNull); + final changed = Completer(); + controller.addListener(() { + if (controller.systemPalette != null && !changed.isCompleted) { + changed.complete(); + } + }); + source.palette = testSystemPalette(); + source.onChanged!(); + await changed.future; + expect(controller.systemPalette, same(source.palette)); + await controller.setGlobalUseSystemPalette(false); + final reads = source.reads; + source.onChanged!(); + expect(source.reads, reads); + }, + ); + + test('A failed palette refresh keeps the last working theme', () async { + final source = MemorySystemPaletteSource(palette: testSystemPalette()); + final controller = TestAppController(systemPaletteSource: source); + await controller.initialize(); + await controller.setGlobalUseSystemPalette(true); + final last = controller.systemPalette; + source.loader = () async => throw StateError('Platform unavailable'); + await controller.setGlobalUseSystemPalette(true); + expect(controller.systemPalette, same(last)); + await controller.setGlobalUseSystemPalette(false); + expect(controller.systemPalette, isNull); + }); + + for (final brightness in Brightness.values) { + testWidgets( + 'Global palette updates the app and courses, preserves agenda source colours and restores $brightness colours', + (tester) async { + final palette = testSystemPalette(); + final controller = TestAppController( + systemPaletteSource: MemorySystemPaletteSource(palette: palette), + ); + await controller.setTheme( + brightness == Brightness.dark ? ThemeMode.dark : ThemeMode.light, + ); + await controller.setCoursePalette(Brightness.dark, 'mocha'); + await controller.importCoursePalette( + jsonEncode(lightTestPalette().toJson()), + ); + await tester.pumpWidget(BitreeApp(controller: controller)); + await tester.tap( + find.descendant( + of: find.byType(NavigationBar), + matching: find.text('设置'), + ), + ); + await tester.pumpAndSettle(); + BuildContext context() => tester.element(find.byType(SettingsScreen)); + final course = Course( + id: 'course', + name: '测试课程', + weekday: 1, + startSection: 1, + endSection: 2, + weeks: const [1], + room: '文萃F704', + ); + final now = DateTime.utc(2026, 11, 17, 12); + final entries = [ + AgendaEntry( + id: 'exam', + title: '考试', + kind: AgendaKind.exam, + startsAt: now.add(const Duration(hours: 1)), + endsAt: now.add(const Duration(hours: 2)), + ), + AgendaEntry( + id: 'ddl', + title: '作业', + kind: AgendaKind.deadline, + startsAt: now.add(const Duration(days: 1)), + source: DeadlineSource.lexue, + ), + AgendaEntry( + id: 'eclass', + title: 'eClass 作业', + kind: AgendaKind.deadline, + startsAt: now.add(const Duration(days: 1)), + source: DeadlineSource.eclass, + ), + ]; + final originalTheme = Theme.of(context()); + final originalCourse = courseColors(context(), course); + final originalAgenda = [ + for (final entry in entries) + agendaTimelineColors(context(), entry, now), + ]; + await controller.setGlobalUseSystemPalette(true); + await tester.pumpAndSettle(); + final theme = Theme.of(context()); + expect(theme.colorScheme, palette.colorScheme(brightness)); + expect(theme.scaffoldBackgroundColor, theme.colorScheme.surface); + expect( + theme.scaffoldBackgroundColor, + isNot(originalTheme.scaffoldBackgroundColor), + ); + expect( + theme.extension()!.palette, + controller.paletteFor(brightness), + ); + final colors = courseColors(context(), course); + expect( + colors.fill, + palette.courseColors(CampusArea.wencui, brightness).fill, + ); + expect(colors.fill, isNot(originalCourse.fill)); + for (var i = 0; i < entries.length; i++) { + expect( + agendaTimelineColors(context(), entries[i], now), + originalAgenda[i], + ); + } + final overdue = entries.last.copyWith(); + expect( + agendaTimelineColors( + context(), + overdue, + now.add(const Duration(days: 2)), + ), + originalAgenda.last, + ); + expect( + agendaTimelineColors( + context(), + overdue.copyWith(done: true), + now, + ).fill.a, + 0.5, + ); + expect(controller.widgetUseSystemPalette, isFalse); + await controller.setGlobalUseSystemPalette(false); + await tester.pumpAndSettle(); + expect(Theme.of(context()).colorScheme, originalTheme.colorScheme); + expect(courseColors(context(), course), originalCourse); + for (var i = 0; i < entries.length; i++) { + expect( + agendaTimelineColors(context(), entries[i], now), + originalAgenda[i], + ); + } + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + }, + ); + } +}