From 254872bcd2668d0160a8a971637f74a524ddcfa8 Mon Sep 17 00:00:00 2001 From: dongdigua Date: Thu, 29 Oct 2026 09:51:21 +0800 Subject: [PATCH 1/9] feat(ui): palette --- docs/timetable-color-preview.html | 295 ++++++++++++++++++++++++++++++ docs/ui-design.md | 10 + lib/app/controller.dart | 34 ++++ lib/app/ui/settings_screen.dart | 73 +++++++- lib/main.dart | 7 +- lib/ui/course_palette.dart | 79 ++++++++ lib/ui/theme.dart | 15 +- test/building_palette_test.dart | 83 +++++++++ test/developer_settings_test.dart | 122 ++++++++++++ test/support/test_app.dart | 3 +- 10 files changed, 713 insertions(+), 8 deletions(-) create mode 100644 docs/timetable-color-preview.html create mode 100644 lib/ui/course_palette.dart create mode 100644 test/developer_settings_test.dart diff --git a/docs/timetable-color-preview.html b/docs/timetable-color-preview.html new file mode 100644 index 0000000..9164560 --- /dev/null +++ b/docs/timetable-color-preview.html @@ -0,0 +1,295 @@ + + + + + + + Bitree · Catppuccin 课表配色预览 + + + +
+
+
BITREE / COLOR STUDY
+

同样的混色,比较不同配色。

+

候选卡片 alpha 固定为 1。使用 App 深色模式相同的混色比例:底色向强调色混合 24%,进度填充再向强调色混合 16%,地点文字向标题色混合 80%。背景、今天列和日期高亮保持原有颜色。

+
+
+ Catppuccin · 官方配色 +
+ + + + +
+
+
+ 比较之前的三组候选 +
+ 选择候选配色 +
+ + + +
+
+
+
+ Alpha = 1 · 底色混色 24% · 进度混色 16% · 地点文字混色 80% + + +
+
+
+

当前配色

深色卡片 · 白色文字 · 原有背景

对照
+
+
+
+

Catppuccin Mocha

不透明卡片 · 原有背景

推荐
+
+
+
+
+

Catppuccin Mocha · 颜色细节

对比度按最终不透明卡片色计算。所有候选使用 App 深色模式的混色比例。

+
+

+ +
+
Catppuccin 颜色来自 官方色表(v1.8.0)。综教使用 Blue,文萃使用 Green,理科使用 Yellow,文体使用 Mauve,工训使用 Peach。卡片与进度填充均完全不透明。Catppuccin 候选以各版本 Base 为卡片底色,向片区强调色混合 24%;其余候选以原建议底色为起点。进度色再向强调色混合 16%,地点文字向标题色混合 80%,与 App 深色模式一致。原有背景、今天列和日期高亮不变。可选隐藏隔行底纹;默认保留。预览不会修改应用配色。
+ +
+ + + diff --git a/docs/ui-design.md b/docs/ui-design.md index 7f0632e..c2ef3c6 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -50,6 +50,16 @@ 深色课表将楼栋底色向同色系强调色提亮,课程块、地点文字与进度填充均使用 不透明色。当天列使用中性表面色,避免铜色罩染;日程时间轴沿用原有配色和渐变。 +在设置的“关于 BITree”弹窗中连续点击图标 5 次(相邻点击不超过 2 秒), +可解锁本次运行的开发者模式,并显示“更多设置”。开发者模式不持久化; +深色模式调色板选项会独立保存,重启后仍应用已选配色。 +默认选项保留原有楼栋配色;Mocha、Macchiato、Frappé 使用 +[Catppuccin](https://github.com/catppuccin/palette) 的 Base、Text 和片区强调色, +依次以蓝、绿、黄、紫、桃色对应北部教学、文萃、南部理科、东部设施和南部工科, +未知地点使用中性色。底色向强调色混合 24%,已上时段再混合 16%,次要文字 +从底色向正文色混合 80%。这些选项只作用于应用内的深色课程块,不改变全局 +背景、当天列、浅色模式或 Android 小组件的默认配色。 + 当天课程保留 1.5px 同色系轮廓;正在上的课程用本楼栋底色与强调色 混合填充已上完的部分。轮廓不占文字空间。时间线独立绘制在课表上层,贯穿七列: 当天保持 2px 原色,两端略收进卡片边缘,避免在侧边间隙露出短点。 diff --git a/lib/app/controller.dart b/lib/app/controller.dart index 292f991..342af96 100644 --- a/lib/app/controller.dart +++ b/lib/app/controller.dart @@ -11,6 +11,7 @@ import '../schedule/store.dart'; import '../school/controller.dart'; import '../school/service.dart'; import '../school/storage.dart'; +import '../ui/course_palette.dart'; /// Composes feature state and owns application startup and appearance. /// Feature changes are observed by their views, not relayed through this object. @@ -44,9 +45,14 @@ class AppController extends ChangeNotifier { bool _agendaViewChosen = false; bool _ready = false; ThemeMode _themeMode = ThemeMode.system; + bool _developerMode = false; + DarkCoursePalette _darkCoursePalette = DarkCoursePalette.standard; + Future _coursePaletteWrite = Future.value(); bool get ready => _ready; ThemeMode get themeMode => _themeMode; + bool get developerMode => _developerMode; + DarkCoursePalette get darkCoursePalette => _darkCoursePalette; Future initialize() async { await timetable.load(); @@ -58,6 +64,15 @@ class AppController extends ChangeNotifier { } catch (failure, stack) { AppDiagnostics.instance.report('读取外观设置', failure, stack); } + try { + final savedPalette = await _preferences.getString('darkCoursePalette'); + _darkCoursePalette = DarkCoursePalette.values + .where((palette) => palette.name == savedPalette) + .firstOrNull ?? + DarkCoursePalette.standard; + } catch (failure, stack) { + AppDiagnostics.instance.report('读取深色课程配色', failure, stack); + } try { final savedView = await _preferences.getString('agendaViewMode'); if (!_agendaViewChosen) { @@ -86,6 +101,25 @@ class AppController extends ChangeNotifier { } } + void unlockDeveloperMode() { + if (_developerMode) return; + _developerMode = true; + notifyListeners(); + } + + Future setDarkCoursePalette(DarkCoursePalette value) { + _darkCoursePalette = value; + notifyListeners(); + _coursePaletteWrite = _coursePaletteWrite.then((_) async { + try { + await _preferences.setString('darkCoursePalette', value.name); + } catch (failure, stack) { + AppDiagnostics.instance.report('保存深色课程配色', failure, stack); + } + }); + return _coursePaletteWrite; + } + /// Publish immediately and serialize writes so rapid toggles cannot save an /// older choice after a newer one. Preference failures do not alter entries. Future setAgendaViewMode(AgendaViewMode value) { diff --git a/lib/app/ui/settings_screen.dart b/lib/app/ui/settings_screen.dart index 2937583..5615810 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'; @@ -8,6 +10,7 @@ import '../../school/ui/school_screen.dart'; import '../../ui/app_menu_anchor.dart'; import '../../ui/app_menu_trigger.dart'; import '../../ui/app_page.dart'; +import '../../ui/course_palette.dart'; import '../../ui/external_link.dart'; import '../../updates/version.dart'; import '../controller.dart'; @@ -70,6 +73,9 @@ class SettingsScreen extends StatelessWidget { final builtAt = appBuildTime?.toLocal(); if (!context.mounted) return; + var iconTaps = 0; + Timer? resetTaps; + await showDialog( context: context, builder: (dialogContext) { @@ -90,7 +96,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( @@ -193,6 +230,7 @@ class SettingsScreen extends StatelessWidget { ); }, ); + resetTaps?.cancel(); } @override @@ -248,6 +286,39 @@ class SettingsScreen extends StatelessWidget { controller.setTheme(selection.single), ), ]), + if (controller.developerMode) + section('更多设置', [ + LayoutBuilder( + builder: (context, constraints) => AppMenuAnchor( + menuWidth: constraints.maxWidth, + menuChildren: [ + for (final palette in DarkCoursePalette.values) + Semantics( + selected: controller.darkCoursePalette == palette, + child: AppMenuItemButton( + onPressed: () => + controller.setDarkCoursePalette(palette), + trailingIcon: + controller.darkCoursePalette == palette + ? const Icon(Icons.check_rounded, size: 18) + : const SizedBox(width: 18), + child: Text(palette.label), + ), + ), + ], + builder: (context, menu, isOpen) => ListTile( + contentPadding: tilePadding, + leading: const Icon(Icons.palette_outlined), + title: const Text('深色模式调色板'), + subtitle: Text( + '${controller.darkCoursePalette.label} · 仅影响深色课程配色', + ), + trailing: AppMenuArrow(isOpen: isOpen), + onTap: () => isOpen ? menu.close() : menu.open(), + ), + ), + ), + ]), if (controller.timetable.schedule != null) section('本地课表', [ LayoutBuilder( diff --git a/lib/main.dart b/lib/main.dart index 71168f5..f74dfb7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -54,15 +54,16 @@ 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( title: 'BITree', debugShowCheckedModeBanner: false, theme: lightTheme, - darkTheme: darkTheme, + darkTheme: bitreeTheme( + Brightness.dark, + darkCoursePalette: controller.darkCoursePalette, + ), themeMode: controller.themeMode, locale: const Locale('zh', 'CN'), supportedLocales: const [Locale('zh', 'CN'), Locale('en')], diff --git a/lib/ui/course_palette.dart b/lib/ui/course_palette.dart new file mode 100644 index 0000000..75b313e --- /dev/null +++ b/lib/ui/course_palette.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; + +import '../schedule/building_palette.dart'; + +enum DarkCoursePalette { + standard('默认'), + mocha('Mocha'), + macchiato('Macchiato'), + frappe('Frappé'); + + const DarkCoursePalette(this.label); + + final String label; + + BuildingColorValues colorsFor(BuildingPalette building) { + // Catppuccin's Base, Text and area accents. Keep the generated building + // table intact so the default palette and Android widget retain their colours. + final colors = switch (this) { + standard => null, + mocha => ( + base: 0xFF1E1E2E, + text: 0xFFCDD6F4, + blue: 0xFF89B4FA, + green: 0xFFA6E3A1, + yellow: 0xFFF9E2AF, + mauve: 0xFFCBA6F7, + peach: 0xFFFAB387, + neutral: 0xFFBAC2DE, + ), + macchiato => ( + base: 0xFF24273A, + text: 0xFFCAD3F5, + blue: 0xFF8AADF4, + green: 0xFFA6DA95, + yellow: 0xFFEED49F, + mauve: 0xFFC6A0F6, + peach: 0xFFF5A97F, + neutral: 0xFFB8C0E0, + ), + frappe => ( + base: 0xFF303446, + text: 0xFFC6D0F5, + blue: 0xFF8CAAEE, + green: 0xFFA6D189, + yellow: 0xFFE5C890, + mauve: 0xFFCA9EE6, + peach: 0xFFEF9F76, + neutral: 0xFFB5BFE2, + ), + }; + if (colors == null) return building.dark; + return ( + fill: colors.base, + text: colors.text, + accent: switch (building.area) { + CampusArea.northTeaching => colors.blue, + CampusArea.wencui => colors.green, + CampusArea.southScience => colors.yellow, + CampusArea.eastFacilities => colors.mauve, + CampusArea.southEngineering => colors.peach, + null => colors.neutral, + }, + ); + } +} + +class CoursePaletteTheme extends ThemeExtension { + const CoursePaletteTheme(this.darkPalette); + + final DarkCoursePalette darkPalette; + + @override + CoursePaletteTheme copyWith({DarkCoursePalette? darkPalette}) => + CoursePaletteTheme(darkPalette ?? this.darkPalette); + + @override + CoursePaletteTheme lerp(CoursePaletteTheme? other, double t) => + other == null || t < 0.5 ? this : other; +} diff --git a/lib/ui/theme.dart b/lib/ui/theme.dart index 9c2b8f3..48176c0 100644 --- a/lib/ui/theme.dart +++ b/lib/ui/theme.dart @@ -2,11 +2,15 @@ import 'package:flutter/material.dart'; import '../schedule/building_palette.dart'; import '../schedule/model.dart'; +import 'course_palette.dart'; import 'design.dart'; const copper = Color(0xFFA85E3E); -ThemeData bitreeTheme(Brightness brightness) { +ThemeData bitreeTheme( + Brightness brightness, { + DarkCoursePalette darkCoursePalette = DarkCoursePalette.standard, +}) { final dark = brightness == Brightness.dark; final scheme = ColorScheme.fromSeed( @@ -52,6 +56,7 @@ ThemeData bitreeTheme(Brightness brightness) { ); return ThemeData( useMaterial3: true, + extensions: [CoursePaletteTheme(darkCoursePalette)], colorScheme: scheme, scaffoldBackgroundColor: scheme.surface, fontFamilyFallback: const [ @@ -281,8 +286,12 @@ 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 darkPalette = + theme.extension()?.darkPalette ?? + DarkCoursePalette.standard; + final colors = dark ? darkPalette.colorsFor(palette) : 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. diff --git a/test/building_palette_test.dart b/test/building_palette_test.dart index d9df548..b382e17 100644 --- a/test/building_palette_test.dart +++ b/test/building_palette_test.dart @@ -4,6 +4,7 @@ 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'; @@ -167,6 +168,88 @@ void main() { } }); + testWidgets( + 'Optional palettes preserve surfaces and light courses, with opaque readable dark courses', + (tester) async { + for (final palette in DarkCoursePalette.values) { + for (final brightness in Brightness.values) { + final originalTheme = bitreeTheme(brightness); + final theme = bitreeTheme(brightness, darkCoursePalette: 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 == Brightness.light) { + expect(colors.fill, Color(building.light.fill)); + expect(colors.text, Color(building.light.text)); + expect(colors.accent, Color(building.light.accent)); + } else if (palette == DarkCoursePalette.standard) { + 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 (palette) { + DarkCoursePalette.mocha => 0xFFCDD6F4, + DarkCoursePalette.macchiato => 0xFFCAD3F5, + DarkCoursePalette.frappe => 0xFFC6D0F5, + DarkCoursePalette.standard => throw StateError('default'), + })); + if (building.area == CampusArea.wencui) { + expect(colors.accent, Color(switch (palette) { + DarkCoursePalette.mocha => 0xFFA6E3A1, + DarkCoursePalette.macchiato => 0xFFA6DA95, + DarkCoursePalette.frappe => 0xFFA6D189, + DarkCoursePalette.standard => throw StateError('default'), + })); + } + } + 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); + final a = foreground.computeLuminance(); + final b = background.computeLuminance(); + expect( + (math.max(a, b) + 0.05) / (math.min(a, b) + 0.05), + greaterThanOrEqualTo(4.5), + reason: '${building.name} $brightness $palette', + ); + } + } + return const SizedBox.shrink(); + }), + )); + } + } + }, + ); + testWidgets( 'Course colours stay opaque and readable before and during class', (tester) async { diff --git a/test/developer_settings_test.dart b/test/developer_settings_test.dart new file mode 100644 index 0000000..25622df --- /dev/null +++ b/test/developer_settings_test.dart @@ -0,0 +1,122 @@ +import 'package:bitree/app/ui/settings_screen.dart'; +import 'package:bitree/main.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 '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.darkCoursePalette, DarkCoursePalette.standard); + first.unlockDeveloperMode(); + expect(first.developerMode, isTrue); + final writes = [ + first.setDarkCoursePalette(DarkCoursePalette.mocha), + first.setDarkCoursePalette(DarkCoursePalette.macchiato), + first.setDarkCoursePalette(DarkCoursePalette.frappe), + ]; + await Future.wait(writes); + + final second = TestAppController(preferences: preferences); + await second.initialize(); + expect(second.developerMode, isFalse); + expect(second.darkCoursePalette, DarkCoursePalette.frappe); + await second.setDarkCoursePalette(DarkCoursePalette.standard); + + final third = TestAppController(preferences: preferences); + await third.initialize(); + expect(third.darkCoursePalette, DarkCoursePalette.standard); + }); + + 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.darkCoursePalette, DarkCoursePalette.standard); + expect(controller.developerMode, isFalse); + }); + + testWidgets('Five consecutive About icon taps reveal the palette menu', ( + tester, + ) async { + 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.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); + 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); + await tester.scrollUntilVisible( + find.text('深色模式调色板'), + -200, + scrollable: scrollable, + ); + await tester.tap(find.text('深色模式调色板')); + await tester.pumpAndSettle(); + for (final label in ['默认', 'Mocha', 'Macchiato', 'Frappé']) { + expect(find.text(label), findsOneWidget); + } + await tester.tap(find.text('Mocha')); + await tester.pumpAndSettle(); + expect(controller.darkCoursePalette, DarkCoursePalette.mocha); + final theme = Theme.of(tester.element(find.byType(SettingsScreen))); + expect( + theme.extension()!.darkPalette, + DarkCoursePalette.mocha, + ); + expect(theme.colorScheme.surface, const Color(0xFF171717)); + expect(theme.colorScheme.surfaceContainer, const Color(0xFF282828)); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + }); +} diff --git a/test/support/test_app.dart b/test/support/test_app.dart index 751e0f0..63963ef 100644 --- a/test/support/test_app.dart +++ b/test/support/test_app.dart @@ -113,11 +113,12 @@ class TestAppController extends AppController { AgendaController? agenda, List entries = const [], MemorySchoolStorage? schoolStorage, + MemoryPreferences? preferences, }) : super( store: MemoryScheduleStore(schedules: schedules, selected: schedule), school: SchoolService(), schoolStorage: schoolStorage ?? MemorySchoolStorage(), - preferences: MemoryPreferences(), + preferences: preferences ?? MemoryPreferences(), agenda: agenda ?? AgendaController(MemoryAgendaStore(entries)), academics: AcademicController(MemoryAcademicStore()), ) { From 4fe045274f00f5061dd2ee90e0f04acbd3294de4 Mon Sep 17 00:00:00 2001 From: dongdigua Date: Thu, 29 Oct 2026 10:16:14 +0800 Subject: [PATCH 2/9] feat(ui): palette for widget --- .../kotlin/dev/so1ve/bitree/BuildingColors.kt | 62 +++- .../kotlin/dev/so1ve/bitree/MainActivity.kt | 3 + .../dev/so1ve/bitree/TimetableWidget.kt | 3 + .../src/main/res/layout/timetable_widget.xml | 71 +++-- docs/timetable-color-preview.html | 295 ------------------ docs/ui-design.md | 9 +- lib/app/controller.dart | 11 +- lib/app/ui/settings_screen.dart | 13 +- lib/core/platform/desktop_widget.dart | 14 +- lib/ui/course_palette.dart | 30 +- test/building_palette_test.dart | 186 ++++++----- test/developer_settings_test.dart | 79 +++-- 12 files changed, 316 insertions(+), 460 deletions(-) delete mode 100644 docs/timetable-color-preview.html 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..4f18580 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 var darkColors: Map? = null + private const val PREFERENCES = "widget_appearance" + private const val DARK_COLORS = "darkCourseColors" private val otherCampuses = Regex("中关村|西山|珠海|雄安|怀来") private val spacing = Regex("[\\s\\u00A0\\u3000·._\\-—–()()\\[\\]【】]") + fun saveDarkPalette(context: Context, colors: Map>) { + context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit() + .putString(DARK_COLORS, JSONObject(colors).toString()).apply() + darkColors = colors.mapValues { (_, values) -> + values.map { it.toInt() }.toIntArray() + } + } + + private fun readDarkPalette(context: Context): Map { + return darkColors ?: run { + try { + val saved = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + .getString(DARK_COLORS, "{}") ?: "{}" + 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 { darkColors = it } + } + } + + fun widgetBackground(context: Context): Int { + val fallback = context.getColor(R.color.widget_surface) + if (!isDark(context)) return fallback + return readDarkPalette(context)["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,10 @@ 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 + if (!isDark(context)) return palette.light + return readDarkPalette(context)[building?.area ?: "neutral"] ?: palette.dark } private fun read(context: Context): Table { @@ -52,7 +97,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..257892b 100644 --- a/android/app/src/main/kotlin/dev/so1ve/bitree/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/so1ve/bitree/MainActivity.kt @@ -10,6 +10,9 @@ class MainActivity : FlutterActivity() { MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "dev.so1ve.bitree/widget") .setMethodCallHandler { call, result -> if (call.method == "update") { + call.argument>>("darkCourseColors")?.let { + BuildingColors.saveDarkPalette(this, it) + } TimetableWidget.updateAll(this) result.success(null) } else { 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..755813c 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,9 @@ open class TimetableWidget : AppWidgetProvider() { ) widgets.forEach { (id, columns) -> val views = RemoteViews(context.packageName, R.layout.timetable_widget) + views.setInt( + R.id.widget_content, "setBackgroundColor", BuildingColors.widgetBackground(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/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/docs/timetable-color-preview.html b/docs/timetable-color-preview.html deleted file mode 100644 index 9164560..0000000 --- a/docs/timetable-color-preview.html +++ /dev/null @@ -1,295 +0,0 @@ - - - - - - - Bitree · Catppuccin 课表配色预览 - - - -
-
-
BITREE / COLOR STUDY
-

同样的混色,比较不同配色。

-

候选卡片 alpha 固定为 1。使用 App 深色模式相同的混色比例:底色向强调色混合 24%,进度填充再向强调色混合 16%,地点文字向标题色混合 80%。背景、今天列和日期高亮保持原有颜色。

-
-
- Catppuccin · 官方配色 -
- - - - -
-
-
- 比较之前的三组候选 -
- 选择候选配色 -
- - - -
-
-
-
- Alpha = 1 · 底色混色 24% · 进度混色 16% · 地点文字混色 80% - - -
-
-
-

当前配色

深色卡片 · 白色文字 · 原有背景

对照
-
-
-
-

Catppuccin Mocha

不透明卡片 · 原有背景

推荐
-
-
-
-
-

Catppuccin Mocha · 颜色细节

对比度按最终不透明卡片色计算。所有候选使用 App 深色模式的混色比例。

-
-

- -
-
Catppuccin 颜色来自 官方色表(v1.8.0)。综教使用 Blue,文萃使用 Green,理科使用 Yellow,文体使用 Mauve,工训使用 Peach。卡片与进度填充均完全不透明。Catppuccin 候选以各版本 Base 为卡片底色,向片区强调色混合 24%;其余候选以原建议底色为起点。进度色再向强调色混合 16%,地点文字向标题色混合 80%,与 App 深色模式一致。原有背景、今天列和日期高亮不变。可选隐藏隔行底纹;默认保留。预览不会修改应用配色。
- -
- - - diff --git a/docs/ui-design.md b/docs/ui-design.md index c2ef3c6..46b75db 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -57,8 +57,13 @@ [Catppuccin](https://github.com/catppuccin/palette) 的 Base、Text 和片区强调色, 依次以蓝、绿、黄、紫、桃色对应北部教学、文萃、南部理科、东部设施和南部工科, 未知地点使用中性色。底色向强调色混合 24%,已上时段再混合 16%,次要文字 -从底色向正文色混合 80%。这些选项只作用于应用内的深色课程块,不改变全局 -背景、当天列、浅色模式或 Android 小组件的默认配色。 +从底色向正文色混合 80%。这些选项作用于应用和 Android 小组件中的深色课程块。 +深色小组件背景直接使用所选调色板的 Base:Mocha 为 `#1E1E2E`, +Macchiato 为 `#24273A`,Frappé 为 `#303446`;应用全局背景、当天列、浅色模式 +和非课程日程的配色保持原样。 +切换调色板会同步最终课程色值和小组件背景,并立即刷新小组件;应用启动时也会同步一次。 +Android 端保存这份色值,供应用关闭后的小组件刷新使用;选择“默认”会清除覆盖色, +恢复小组件原有的背景和楼栋配色。 当天课程保留 1.5px 同色系轮廓;正在上的课程用本楼栋底色与强调色 混合填充已上完的部分。轮廓不占文字空间。时间线独立绘制在课表上层,贯穿七列: diff --git a/lib/app/controller.dart b/lib/app/controller.dart index 342af96..54b7987 100644 --- a/lib/app/controller.dart +++ b/lib/app/controller.dart @@ -6,6 +6,7 @@ import '../agenda/controller.dart'; import '../agenda/view_mode.dart'; import '../classrooms/repository.dart'; import '../core/diagnostics.dart'; +import '../core/platform/desktop_widget.dart'; import '../schedule/controller.dart'; import '../schedule/store.dart'; import '../school/controller.dart'; @@ -66,17 +67,22 @@ class AppController extends ChangeNotifier { } try { final savedPalette = await _preferences.getString('darkCoursePalette'); - _darkCoursePalette = DarkCoursePalette.values + _darkCoursePalette = + DarkCoursePalette.values .where((palette) => palette.name == savedPalette) .firstOrNull ?? DarkCoursePalette.standard; } catch (failure, stack) { AppDiagnostics.instance.report('读取深色课程配色', failure, stack); } + await updateDesktopWidget( + darkCourseColors: _darkCoursePalette.widgetColors, + ); try { final savedView = await _preferences.getString('agendaViewMode'); if (!_agendaViewChosen) { - agendaViewMode.value = AgendaViewMode.values + agendaViewMode.value = + AgendaViewMode.values .where((mode) => mode.name == savedView) .firstOrNull ?? AgendaViewMode.timeline; @@ -113,6 +119,7 @@ class AppController extends ChangeNotifier { _coursePaletteWrite = _coursePaletteWrite.then((_) async { try { await _preferences.setString('darkCoursePalette', value.name); + await updateDesktopWidget(darkCourseColors: value.widgetColors); } catch (failure, stack) { AppDiagnostics.instance.report('保存深色课程配色', failure, stack); } diff --git a/lib/app/ui/settings_screen.dart b/lib/app/ui/settings_screen.dart index 5615810..bac8a63 100644 --- a/lib/app/ui/settings_screen.dart +++ b/lib/app/ui/settings_screen.dart @@ -111,9 +111,9 @@ class SettingsScreen extends StatelessWidget { if (iconTaps < 5) return; controller.unlockDeveloperMode(); Navigator.pop(dialogContext); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('已开启开发者模式')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('已开启开发者模式'))); }, child: SizedBox( width: 48, @@ -298,8 +298,7 @@ class SettingsScreen extends StatelessWidget { child: AppMenuItemButton( onPressed: () => controller.setDarkCoursePalette(palette), - trailingIcon: - controller.darkCoursePalette == palette + trailingIcon: controller.darkCoursePalette == palette ? const Icon(Icons.check_rounded, size: 18) : const SizedBox(width: 18), child: Text(palette.label), @@ -310,9 +309,7 @@ class SettingsScreen extends StatelessWidget { contentPadding: tilePadding, leading: const Icon(Icons.palette_outlined), title: const Text('深色模式调色板'), - subtitle: Text( - '${controller.darkCoursePalette.label} · 仅影响深色课程配色', - ), + subtitle: Text(controller.darkCoursePalette.label), trailing: AppMenuArrow(isOpen: isOpen), onTap: () => isOpen ? menu.close() : menu.open(), ), diff --git a/lib/core/platform/desktop_widget.dart b/lib/core/platform/desktop_widget.dart index 67fdc3c..b7cab29 100644 --- a/lib/core/platform/desktop_widget.dart +++ b/lib/core/platform/desktop_widget.dart @@ -5,12 +5,18 @@ 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. -Future updateDesktopWidget() async { +/// not turn a completed data write into a failed save. Supply [darkCourseColors] +/// to persist a new palette, or an empty map to restore native default colours. +/// Content-only refreshes leave the saved palette unchanged. +Future updateDesktopWidget({ + Map>? darkCourseColors, +}) async { if (!Platform.isAndroid) return; try { - await const MethodChannel('dev.so1ve.bitree/widget') - .invokeMethod('update'); + await const MethodChannel('dev.so1ve.bitree/widget').invokeMethod( + 'update', + darkCourseColors == null ? null : {'darkCourseColors': darkCourseColors}, + ); } catch (failure, stack) { AppDiagnostics.instance.report('更新桌面组件', failure, stack); } diff --git a/lib/ui/course_palette.dart b/lib/ui/course_palette.dart index 75b313e..d32ac7f 100644 --- a/lib/ui/course_palette.dart +++ b/lib/ui/course_palette.dart @@ -12,9 +12,31 @@ enum DarkCoursePalette { final String label; - BuildingColorValues colorsFor(BuildingPalette building) { + BuildingColorValues colorsFor(BuildingPalette building) => + _colorsForArea(building.area) ?? building.dark; + + /// Opaque colour pairs for native courses by area, plus a surface pair. + /// An empty map restores the widget's default colours. + Map> get widgetColors { + final surface = _colorsForArea(null); + if (surface == null) return {}; + final result = >{ + 'surface': [surface.fill, surface.text], + }; + for (final area in [...CampusArea.values, null]) { + final colors = _colorsForArea(area); + if (colors == null) continue; + result[area?.name ?? 'neutral'] = [ + Color.lerp(Color(colors.fill), Color(colors.accent), 0.24)!.toARGB32(), + colors.text, + ]; + } + return result; + } + + BuildingColorValues? _colorsForArea(CampusArea? area) { // Catppuccin's Base, Text and area accents. Keep the generated building - // table intact so the default palette and Android widget retain their colours. + // table intact so both renderers can restore their default colours. final colors = switch (this) { standard => null, mocha => ( @@ -48,11 +70,11 @@ enum DarkCoursePalette { neutral: 0xFFB5BFE2, ), }; - if (colors == null) return building.dark; + if (colors == null) return null; return ( fill: colors.base, text: colors.text, - accent: switch (building.area) { + accent: switch (area) { CampusArea.northTeaching => colors.blue, CampusArea.wencui => colors.green, CampusArea.southScience => colors.yellow, diff --git a/test/building_palette_test.dart b/test/building_palette_test.dart index b382e17..81fc1ee 100644 --- a/test/building_palette_test.dart +++ b/test/building_palette_test.dart @@ -61,10 +61,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', () { @@ -169,9 +172,28 @@ void main() { }); testWidgets( - 'Optional palettes preserve surfaces and light courses, with opaque readable dark courses', + 'Optional palettes preserve surfaces and export matching opaque widget course colours', (tester) async { for (final palette in DarkCoursePalette.values) { + final widgetColors = palette.widgetColors; + if (palette == DarkCoursePalette.standard) { + expect(widgetColors, isEmpty); + } else { + expect( + widgetColors.keys, + unorderedEquals([ + ...CampusArea.values.map((area) => area.name), + 'neutral', + 'surface', + ]), + ); + expect(widgetColors['surface']!.first, switch (palette) { + DarkCoursePalette.mocha => 0xFF1E1E2E, + DarkCoursePalette.macchiato => 0xFF24273A, + DarkCoursePalette.frappe => 0xFF303446, + DarkCoursePalette.standard => throw StateError('default'), + }); + } for (final brightness in Brightness.values) { final originalTheme = bitreeTheme(brightness); final theme = bitreeTheme(brightness, darkCoursePalette: palette); @@ -180,71 +202,91 @@ void main() { 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 == Brightness.light) { - expect(colors.fill, Color(building.light.fill)); - expect(colors.text, Color(building.light.text)); - expect(colors.accent, Color(building.light.accent)); - } else if (palette == DarkCoursePalette.standard) { - 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 (palette) { - DarkCoursePalette.mocha => 0xFFCDD6F4, - DarkCoursePalette.macchiato => 0xFFCAD3F5, - DarkCoursePalette.frappe => 0xFFC6D0F5, - DarkCoursePalette.standard => throw StateError('default'), - })); - if (building.area == CampusArea.wencui) { - expect(colors.accent, Color(switch (palette) { - DarkCoursePalette.mocha => 0xFFA6E3A1, - DarkCoursePalette.macchiato => 0xFFA6DA95, - DarkCoursePalette.frappe => 0xFFA6D189, - DarkCoursePalette.standard => throw StateError('default'), - })); + 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 == Brightness.dark && + palette != DarkCoursePalette.standard) { + expect( + widgetColors[building.area?.name ?? 'neutral'], + [colors.fill.toARGB32(), colors.text.toARGB32()], + reason: '${building.name} uses the same widget colours', + ); + } + if (brightness == Brightness.light) { + expect(colors.fill, Color(building.light.fill)); + expect(colors.text, Color(building.light.text)); + expect(colors.accent, Color(building.light.accent)); + } else if (palette == DarkCoursePalette.standard) { + 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 (palette) { + DarkCoursePalette.mocha => 0xFFCDD6F4, + DarkCoursePalette.macchiato => 0xFFCAD3F5, + DarkCoursePalette.frappe => 0xFFC6D0F5, + DarkCoursePalette.standard => throw StateError( + 'default', + ), + }), + ); + if (building.area == CampusArea.wencui) { + expect( + colors.accent, + Color(switch (palette) { + DarkCoursePalette.mocha => 0xFFA6E3A1, + DarkCoursePalette.macchiato => 0xFFA6DA95, + DarkCoursePalette.frappe => 0xFFA6D189, + DarkCoursePalette.standard => throw StateError( + 'default', + ), + }), + ); + } + } + 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); + } } - } - 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); - final a = foreground.computeLuminance(); - final b = background.computeLuminance(); - expect( - (math.max(a, b) + 0.05) / (math.min(a, b) + 0.05), - greaterThanOrEqualTo(4.5), - reason: '${building.name} $brightness $palette', - ); - } - } - return const SizedBox.shrink(); - }), - )); + return const SizedBox.shrink(); + }, + ), + ), + ); } } }, @@ -290,9 +332,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/developer_settings_test.dart b/test/developer_settings_test.dart index 25622df..8821568 100644 --- a/test/developer_settings_test.dart +++ b/test/developer_settings_test.dart @@ -1,7 +1,7 @@ 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:bitree/ui/theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -9,31 +9,34 @@ 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.darkCoursePalette, DarkCoursePalette.standard); - first.unlockDeveloperMode(); - expect(first.developerMode, isTrue); - final writes = [ - first.setDarkCoursePalette(DarkCoursePalette.mocha), - first.setDarkCoursePalette(DarkCoursePalette.macchiato), - first.setDarkCoursePalette(DarkCoursePalette.frappe), - ]; - await Future.wait(writes); + 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.darkCoursePalette, DarkCoursePalette.standard); + first.unlockDeveloperMode(); + expect(first.developerMode, isTrue); + final writes = [ + first.setDarkCoursePalette(DarkCoursePalette.mocha), + first.setDarkCoursePalette(DarkCoursePalette.macchiato), + first.setDarkCoursePalette(DarkCoursePalette.frappe), + ]; + await Future.wait(writes); - final second = TestAppController(preferences: preferences); - await second.initialize(); - expect(second.developerMode, isFalse); - expect(second.darkCoursePalette, DarkCoursePalette.frappe); - await second.setDarkCoursePalette(DarkCoursePalette.standard); + final second = TestAppController(preferences: preferences); + await second.initialize(); + expect(second.developerMode, isFalse); + expect(second.darkCoursePalette, DarkCoursePalette.frappe); + await second.setDarkCoursePalette(DarkCoursePalette.standard); - final third = TestAppController(preferences: preferences); - await third.initialize(); - expect(third.darkCoursePalette, DarkCoursePalette.standard); - }); + final third = TestAppController(preferences: preferences); + await third.initialize(); + expect(third.darkCoursePalette, DarkCoursePalette.standard); + }, + ); test('Unknown saved palette falls back to the current app colours', () async { final preferences = MemoryPreferences(); @@ -47,6 +50,8 @@ void main() { testWidgets('Five consecutive About icon taps reveal the palette menu', ( tester, ) async { + final semantics = tester.ensureSemantics(); + addTearDown(semantics.dispose); PackageInfo.setMockInitialValues( appName: 'BITree', packageName: 'app.bitree', @@ -61,16 +66,20 @@ void main() { final controller = TestAppController(); 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.tap( + find.descendant( + of: find.byType(NavigationBar), + matching: find.text('设置'), + ), + ); await tester.pumpAndSettle(); expect(find.text('更多设置'), findsNothing); - final scrollable = find.descendant( - of: find.byType(SettingsScreen), - matching: find.byType(Scrollable), - ).first; + final scrollable = find + .descendant( + of: find.byType(SettingsScreen), + matching: find.byType(Scrollable), + ) + .first; await tester.scrollUntilVisible( find.text('关于 BITree'), 200, @@ -100,13 +109,15 @@ void main() { -200, scrollable: scrollable, ); + expect(find.text('默认'), findsOneWidget); await tester.tap(find.text('深色模式调色板')); await tester.pumpAndSettle(); for (final label in ['默认', 'Mocha', 'Macchiato', 'Frappé']) { - expect(find.text(label), findsOneWidget); + expect(find.widgetWithText(AppMenuItemButton, label), findsOneWidget); } - await tester.tap(find.text('Mocha')); + await tester.tap(find.widgetWithText(AppMenuItemButton, 'Mocha')); await tester.pumpAndSettle(); + expect(find.text('Mocha'), findsOneWidget); expect(controller.darkCoursePalette, DarkCoursePalette.mocha); final theme = Theme.of(tester.element(find.byType(SettingsScreen))); expect( From 2523026da505e8854504779ade2074d6ce4c86ab Mon Sep 17 00:00:00 2001 From: dongdigua Date: Thu, 29 Oct 2026 10:42:29 +0800 Subject: [PATCH 3/9] feat(ui): widget system palette --- .../kotlin/dev/so1ve/bitree/BuildingColors.kt | 1 + .../kotlin/dev/so1ve/bitree/MainActivity.kt | 9 ++ .../dev/so1ve/bitree/TimetableWidget.kt | 5 +- .../kotlin/dev/so1ve/bitree/WidgetColors.kt | 91 +++++++++++++++++++ .../values-night-v31/widget_system_colors.xml | 7 ++ .../res/values-v31/widget_system_colors.xml | 7 ++ .../main/res/values/widget_system_colors.xml | 7 ++ docs/ui-design.md | 15 +-- lib/app/controller.dart | 34 ++++++- lib/app/ui/settings_screen.dart | 7 ++ lib/core/platform/desktop_widget.dart | 10 +- test/developer_settings_test.dart | 43 +++++++++ test/support/test_app.dart | 8 +- 13 files changed, 232 insertions(+), 12 deletions(-) create mode 100644 android/app/src/main/kotlin/dev/so1ve/bitree/WidgetColors.kt create mode 100644 android/app/src/main/res/values-night-v31/widget_system_colors.xml create mode 100644 android/app/src/main/res/values-v31/widget_system_colors.xml create mode 100644 android/app/src/main/res/values/widget_system_colors.xml 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 4f18580..5d9d7d3 100644 --- a/android/app/src/main/kotlin/dev/so1ve/bitree/BuildingColors.kt +++ b/android/app/src/main/kotlin/dev/so1ve/bitree/BuildingColors.kt @@ -80,6 +80,7 @@ internal object BuildingColors { }.map { it.rule }.distinctBy { it.name } val building = buildings.singleOrNull() val palette = building?.palette ?: data.neutral + WidgetColors.course(context, building?.area)?.let { return it } if (!isDark(context)) return palette.light return readDarkPalette(context)[building?.area ?: "neutral"] ?: palette.dark } 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 257892b..b68be5b 100644 --- a/android/app/src/main/kotlin/dev/so1ve/bitree/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/so1ve/bitree/MainActivity.kt @@ -1,10 +1,16 @@ 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() { + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + TimetableWidget.updateAll(this) + } + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "dev.so1ve.bitree/widget") @@ -13,6 +19,9 @@ class MainActivity : FlutterActivity() { call.argument>>("darkCourseColors")?.let { BuildingColors.saveDarkPalette(this, it) } + call.argument("useSystemPalette")?.let { + WidgetColors.saveUseSystemPalette(this, it) + } TimetableWidget.updateAll(this) result.success(null) } else { 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 755813c..154c3a7 100644 --- a/android/app/src/main/kotlin/dev/so1ve/bitree/TimetableWidget.kt +++ b/android/app/src/main/kotlin/dev/so1ve/bitree/TimetableWidget.kt @@ -76,8 +76,11 @@ open class TimetableWidget : AppWidgetProvider() { widgets.forEach { (id, columns) -> val views = RemoteViews(context.packageName, R.layout.timetable_widget) views.setInt( - R.id.widget_content, "setBackgroundColor", BuildingColors.widgetBackground(context), + 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/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/docs/ui-design.md b/docs/ui-design.md index 46b75db..112da3c 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -58,12 +58,15 @@ 依次以蓝、绿、黄、紫、桃色对应北部教学、文萃、南部理科、东部设施和南部工科, 未知地点使用中性色。底色向强调色混合 24%,已上时段再混合 16%,次要文字 从底色向正文色混合 80%。这些选项作用于应用和 Android 小组件中的深色课程块。 -深色小组件背景直接使用所选调色板的 Base:Mocha 为 `#1E1E2E`, -Macchiato 为 `#24273A`,Frappé 为 `#303446`;应用全局背景、当天列、浅色模式 -和非课程日程的配色保持原样。 -切换调色板会同步最终课程色值和小组件背景,并立即刷新小组件;应用启动时也会同步一次。 -Android 端保存这份色值,供应用关闭后的小组件刷新使用;选择“默认”会清除覆盖色, -恢复小组件原有的背景和楼栋配色。 +Android 12 及以上,小组件背景、标题、次要文字和主题强调色始终读取系统动态色, +不受课程调色板或开关影响。开发者模式中的“小组件使用系统调色板”默认关闭, +关闭时课程沿用自选调色板;开启时仅小组件课程改用系统色,应用内课程保持原样。 +系统课程色按片区使用三组强调色,并通过明度区分同组片区;未知地点使用中性色。 +Android 12 以下保留原有背景和课程配色,深色背景可回退到所选 Catppuccin 的 Base。 +应用全局背景、当天列和非课程日程的配色保持原样。 +切换调色板或开关会同步已选课程色值和开关状态,并立即刷新小组件;应用启动时也会同步。 +Android 端保存这些设置,在应用关闭后刷新时读取当前系统颜色。 +调色板选择和开关都持久化,开发者模式的解锁状态不持久化。 当天课程保留 1.5px 同色系轮廓;正在上的课程用本楼栋底色与强调色 混合填充已上完的部分。轮廓不占文字空间。时间线独立绘制在课表上层,贯穿七列: diff --git a/lib/app/controller.dart b/lib/app/controller.dart index 54b7987..bb3302e 100644 --- a/lib/app/controller.dart +++ b/lib/app/controller.dart @@ -47,12 +47,14 @@ class AppController extends ChangeNotifier { bool _ready = false; ThemeMode _themeMode = ThemeMode.system; bool _developerMode = false; + bool _widgetUseSystemPalette = false; DarkCoursePalette _darkCoursePalette = DarkCoursePalette.standard; Future _coursePaletteWrite = Future.value(); bool get ready => _ready; ThemeMode get themeMode => _themeMode; bool get developerMode => _developerMode; + bool get widgetUseSystemPalette => _widgetUseSystemPalette; DarkCoursePalette get darkCoursePalette => _darkCoursePalette; Future initialize() async { @@ -75,8 +77,15 @@ class AppController extends ChangeNotifier { } catch (failure, stack) { AppDiagnostics.instance.report('读取深色课程配色', failure, stack); } + try { + _widgetUseSystemPalette = + await _preferences.getBool('widgetUseSystemPalette') ?? false; + } catch (failure, stack) { + AppDiagnostics.instance.report('读取小组件调色板设置', failure, stack); + } await updateDesktopWidget( darkCourseColors: _darkCoursePalette.widgetColors, + useSystemPalette: _widgetUseSystemPalette, ); try { final savedView = await _preferences.getString('agendaViewMode'); @@ -115,11 +124,15 @@ class AppController extends ChangeNotifier { Future setDarkCoursePalette(DarkCoursePalette value) { _darkCoursePalette = value; + final useSystemPalette = _widgetUseSystemPalette; notifyListeners(); _coursePaletteWrite = _coursePaletteWrite.then((_) async { try { await _preferences.setString('darkCoursePalette', value.name); - await updateDesktopWidget(darkCourseColors: value.widgetColors); + await updateDesktopWidget( + darkCourseColors: value.widgetColors, + useSystemPalette: useSystemPalette, + ); } catch (failure, stack) { AppDiagnostics.instance.report('保存深色课程配色', failure, stack); } @@ -127,6 +140,25 @@ class AppController extends ChangeNotifier { return _coursePaletteWrite; } + Future setWidgetUseSystemPalette(bool value) { + _widgetUseSystemPalette = value; + final palette = _darkCoursePalette; + notifyListeners(); + // Share the palette write queue so rapid changes reach native code in order. + _coursePaletteWrite = _coursePaletteWrite.then((_) async { + try { + await _preferences.setBool('widgetUseSystemPalette', value); + await updateDesktopWidget( + darkCourseColors: palette.widgetColors, + useSystemPalette: value, + ); + } catch (failure, stack) { + AppDiagnostics.instance.report('保存小组件调色板设置', failure, stack); + } + }); + return _coursePaletteWrite; + } + /// Publish immediately and serialize writes so rapid toggles cannot save an /// older choice after a newer one. Preference failures do not alter entries. Future setAgendaViewMode(AgendaViewMode value) { diff --git a/lib/app/ui/settings_screen.dart b/lib/app/ui/settings_screen.dart index bac8a63..6496c49 100644 --- a/lib/app/ui/settings_screen.dart +++ b/lib/app/ui/settings_screen.dart @@ -315,6 +315,13 @@ class SettingsScreen extends StatelessWidget { ), ), ), + SwitchListTile( + contentPadding: tilePadding, + secondary: const Icon(Icons.auto_awesome_outlined), + title: const Text('小组件使用系统调色板'), + value: controller.widgetUseSystemPalette, + onChanged: controller.setWidgetUseSystemPalette, + ), ]), if (controller.timetable.schedule != null) section('本地课表', [ diff --git a/lib/core/platform/desktop_widget.dart b/lib/core/platform/desktop_widget.dart index b7cab29..1cd64b7 100644 --- a/lib/core/platform/desktop_widget.dart +++ b/lib/core/platform/desktop_widget.dart @@ -7,15 +7,21 @@ 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. Supply [darkCourseColors] /// to persist a new palette, or an empty map to restore native default colours. -/// Content-only refreshes leave the saved palette unchanged. +/// Content-only refreshes leave the saved appearance unchanged. Future updateDesktopWidget({ Map>? darkCourseColors, + bool? useSystemPalette, }) async { if (!Platform.isAndroid) return; try { await const MethodChannel('dev.so1ve.bitree/widget').invokeMethod( 'update', - darkCourseColors == null ? null : {'darkCourseColors': darkCourseColors}, + darkCourseColors == null && useSystemPalette == null + ? null + : { + 'darkCourseColors': ?darkCourseColors, + 'useSystemPalette': ?useSystemPalette, + }, ); } catch (failure, stack) { AppDiagnostics.instance.report('更新桌面组件', failure, stack); diff --git a/test/developer_settings_test.dart b/test/developer_settings_test.dart index 8821568..ac89e29 100644 --- a/test/developer_settings_test.dart +++ b/test/developer_settings_test.dart @@ -38,6 +38,36 @@ void main() { }, ); + 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.setDarkCoursePalette(DarkCoursePalette.mocha), + ]); + + final second = TestAppController(preferences: preferences); + await second.initialize(); + expect(second.widgetUseSystemPalette, isTrue); + expect(second.darkCoursePalette, DarkCoursePalette.mocha); + expect(second.developerMode, isFalse); + await second.setDarkCoursePalette(DarkCoursePalette.standard); + expect(second.widgetUseSystemPalette, isTrue); + await second.setWidgetUseSystemPalette(false); + + final third = TestAppController(preferences: preferences); + await third.initialize(); + expect(third.widgetUseSystemPalette, isFalse); + expect(third.darkCoursePalette, DarkCoursePalette.standard); + }, + ); + test('Unknown saved palette falls back to the current app colours', () async { final preferences = MemoryPreferences(); await preferences.setString('darkCoursePalette', 'retired-palette'); @@ -74,6 +104,7 @@ void main() { ); await tester.pumpAndSettle(); expect(find.text('更多设置'), findsNothing); + expect(find.text('小组件使用系统调色板'), findsNothing); final scrollable = find .descendant( of: find.byType(SettingsScreen), @@ -110,6 +141,17 @@ void main() { scrollable: scrollable, ); expect(find.text('默认'), findsOneWidget); + 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); + await tester.ensureVisible(find.text('深色模式调色板')); await tester.tap(find.text('深色模式调色板')); await tester.pumpAndSettle(); for (final label in ['默认', 'Mocha', 'Macchiato', 'Frappé']) { @@ -119,6 +161,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Mocha'), findsOneWidget); expect(controller.darkCoursePalette, DarkCoursePalette.mocha); + expect(controller.widgetUseSystemPalette, isTrue); final theme = Theme.of(tester.element(find.byType(SettingsScreen))); expect( theme.extension()!.darkPalette, diff --git a/test/support/test_app.dart b/test/support/test_app.dart index 63963ef..849f452 100644 --- a/test/support/test_app.dart +++ b/test/support/test_app.dart @@ -64,12 +64,16 @@ class MemoryAcademicStore extends AcademicStore { } class MemoryPreferences extends Fake implements SharedPreferencesAsync { - final _values = {}; + final _values = {}; @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 { From e79e1b051b1f75236fbebc7ca2ff34967952ef9f Mon Sep 17 00:00:00 2001 From: dongdigua Date: Thu, 29 Oct 2026 11:26:50 +0800 Subject: [PATCH 4/9] feat(ui): full system palette --- .../kotlin/dev/so1ve/bitree/MainActivity.kt | 26 ++ .../kotlin/dev/so1ve/bitree/SystemPalette.kt | 49 +++ lib/agenda/ui/timeline_colors.dart | 33 ++- lib/app/controller.dart | 59 +++- lib/app/ui/settings_screen.dart | 7 + lib/core/platform/system_palette.dart | 31 ++ lib/main.dart | 7 +- lib/ui/system_palette.dart | 139 +++++++++ lib/ui/theme.dart | 19 +- test/developer_settings_test.dart | 14 + test/support/system_palette.dart | 46 +++ test/support/test_app.dart | 3 + test/system_palette_test.dart | 278 ++++++++++++++++++ 13 files changed, 697 insertions(+), 14 deletions(-) create mode 100644 android/app/src/main/kotlin/dev/so1ve/bitree/SystemPalette.kt create mode 100644 lib/core/platform/system_palette.dart create mode 100644 lib/ui/system_palette.dart create mode 100644 test/support/system_palette.dart create mode 100644 test/system_palette_test.dart 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 b68be5b..3c07592 100644 --- a/android/app/src/main/kotlin/dev/so1ve/bitree/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/so1ve/bitree/MainActivity.kt @@ -6,13 +6,33 @@ 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") { @@ -29,4 +49,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/lib/agenda/ui/timeline_colors.dart b/lib/agenda/ui/timeline_colors.dart index 61e5426..141981f 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/system_palette.dart'; import '../deadline_source.dart'; import '../model.dart'; import '../physics_lab_deadlines.dart'; @@ -25,22 +26,34 @@ import '../physics_lab_deadlines.dart'; final paletteName = switch (entry.kind) { AgendaKind.exam => '物理实验中心', - AgendaKind.deadline => isPhysicsLabDeadline(entry) - ? '综合教学楼A' - : switch (entry.source) { - DeadlineSource.lexue => '工程训练中心', - DeadlineSource.eclass => '文化体育中心', - null => null, - }, + AgendaKind.deadline => + isPhysicsLabDeadline(entry) + ? '综合教学楼A' + : switch (entry.source) { + DeadlineSource.lexue => '工程训练中心', + DeadlineSource.eclass => '文化体育中心', + null => null, + }, AgendaKind.event => null, }; - final palette = paletteName == null ? null : buildingPaletteNamed(paletteName); + final palette = paletteName == null + ? null + : buildingPaletteNamed(paletteName); final finished = entry.isFinished(now); - final fadeAll = finished && - (entry.isDeadline || entry.kind == AgendaKind.exam); + final fadeAll = + finished && (entry.isDeadline || entry.kind == AgendaKind.exam); final fillAlpha = fadeAll ? 0.5 : (finished ? 0.55 : 1.0); final foregroundAlpha = fadeAll ? 0.5 : 1.0; if (palette != null) { + final system = theme.extension()?.palette; + if (system != null) { + final colors = system.courseColors(palette.area, theme.brightness); + return ( + fill: colors.fill.withValues(alpha: fillAlpha), + text: colors.text.withValues(alpha: foregroundAlpha), + accent: colors.accent.withValues(alpha: foregroundAlpha), + ); + } final values = theme.brightness == Brightness.dark ? palette.dark : palette.light; diff --git a/lib/app/controller.dart b/lib/app/controller.dart index bb3302e..884949b 100644 --- a/lib/app/controller.dart +++ b/lib/app/controller.dart @@ -7,12 +7,14 @@ 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 '../schedule/controller.dart'; import '../schedule/store.dart'; import '../school/controller.dart'; import '../school/service.dart'; import '../school/storage.dart'; import '../ui/course_palette.dart'; +import '../ui/system_palette.dart'; /// Composes feature state and owns application startup and appearance. /// Feature changes are observed by their views, not relayed through this object. @@ -24,7 +26,9 @@ class AppController extends ChangeNotifier { required this._preferences, required this.agenda, required this.academics, - }) : timetable = TimetableController(store) { + SystemPaletteSource? systemPaletteSource, + }) : _systemPaletteSource = systemPaletteSource ?? SystemPaletteSource(), + timetable = TimetableController(store) { this.school = SchoolController( service: school, storage: schoolStorage, @@ -33,6 +37,7 @@ class AppController extends ChangeNotifier { academics: academics, ); classrooms = ClassroomRepository(this.school); + _systemPaletteSource.watch(() => _refreshSystemPalette()); } final TimetableController timetable; @@ -50,12 +55,22 @@ class AppController extends ChangeNotifier { bool _widgetUseSystemPalette = false; DarkCoursePalette _darkCoursePalette = DarkCoursePalette.standard; 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; DarkCoursePalette get darkCoursePalette => _darkCoursePalette; + bool get globalUseSystemPalette => _globalUseSystemPalette; + SystemPalette? get systemPalette => + _globalUseSystemPalette ? _systemPalette : null; Future initialize() async { await timetable.load(); @@ -83,6 +98,14 @@ class AppController extends ChangeNotifier { } 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( darkCourseColors: _darkCoursePalette.widgetColors, useSystemPalette: _widgetUseSystemPalette, @@ -159,6 +182,37 @@ class AppController extends ChangeNotifier { return _coursePaletteWrite; } + 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. Preference failures do not alter entries. Future setAgendaViewMode(AgendaViewMode value) { @@ -176,6 +230,9 @@ class AppController extends ChangeNotifier { @override void dispose() { + _disposed = true; + _paletteRequest++; + _systemPaletteSource.dispose(); agendaViewMode.dispose(); classrooms.dispose(); school.dispose(); diff --git a/lib/app/ui/settings_screen.dart b/lib/app/ui/settings_screen.dart index 6496c49..e235464 100644 --- a/lib/app/ui/settings_screen.dart +++ b/lib/app/ui/settings_screen.dart @@ -322,6 +322,13 @@ class SettingsScreen extends StatelessWidget { value: controller.widgetUseSystemPalette, onChanged: controller.setWidgetUseSystemPalette, ), + SwitchListTile( + contentPadding: tilePadding, + secondary: const Icon(Icons.color_lens_outlined), + title: const Text('全局系统调色板(实验性)'), + value: controller.globalUseSystemPalette, + onChanged: controller.setGlobalUseSystemPalette, + ), ]), if (controller.timetable.schedule != null) section('本地课表', [ diff --git a/lib/core/platform/system_palette.dart b/lib/core/platform/system_palette.dart new file mode 100644 index 0000000..3b57c0d --- /dev/null +++ b/lib/core/platform/system_palette.dart @@ -0,0 +1,31 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +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 f74dfb7..73c33ed 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -53,16 +53,19 @@ class BitreeApp extends StatelessWidget { @override Widget build(BuildContext context) { - final lightTheme = bitreeTheme(Brightness.light); return ListenableBuilder( listenable: controller, builder: (context, _) => MaterialApp( title: 'BITree', debugShowCheckedModeBanner: false, - theme: lightTheme, + theme: bitreeTheme( + Brightness.light, + systemPalette: controller.systemPalette, + ), darkTheme: bitreeTheme( Brightness.dark, darkCoursePalette: controller.darkCoursePalette, + systemPalette: controller.systemPalette, ), themeMode: controller.themeMode, locale: const Locale('zh', 'CN'), 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 48176c0..cb4d320 100644 --- a/lib/ui/theme.dart +++ b/lib/ui/theme.dart @@ -4,15 +4,18 @@ 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, { DarkCoursePalette darkCoursePalette = DarkCoursePalette.standard, + SystemPalette? systemPalette, }) { final dark = brightness == Brightness.dark; final scheme = + systemPalette?.colorScheme(brightness) ?? ColorScheme.fromSeed( seedColor: copper, brightness: brightness, @@ -56,7 +59,10 @@ ThemeData bitreeTheme( ); return ThemeData( useMaterial3: true, - extensions: [CoursePaletteTheme(darkCoursePalette)], + extensions: [ + CoursePaletteTheme(darkCoursePalette), + if (systemPalette != null) SystemPaletteTheme(systemPalette), + ], colorScheme: scheme, scaffoldBackgroundColor: scheme.surface, fontFamilyFallback: const [ @@ -288,6 +294,17 @@ courseColors(BuildContext context, Course course) { unknownBuildingPalette; 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 darkPalette = theme.extension()?.darkPalette ?? DarkCoursePalette.standard; diff --git a/test/developer_settings_test.dart b/test/developer_settings_test.dart index ac89e29..8472003 100644 --- a/test/developer_settings_test.dart +++ b/test/developer_settings_test.dart @@ -105,6 +105,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('更多设置'), findsNothing); expect(find.text('小组件使用系统调色板'), findsNothing); + expect(find.text('全局系统调色板(实验性)'), findsNothing); final scrollable = find .descendant( of: find.byType(SettingsScreen), @@ -151,6 +152,19 @@ void main() { 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(); 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 849f452..9c3da9a 100644 --- a/test/support/test_app.dart +++ b/test/support/test_app.dart @@ -5,6 +5,7 @@ import 'package:bitree/agenda/controller.dart'; import 'package:bitree/agenda/model.dart'; import 'package:bitree/agenda/store.dart'; import 'package:bitree/app/controller.dart'; +import 'package:bitree/core/platform/system_palette.dart'; import 'package:bitree/schedule/model.dart'; import 'package:bitree/schedule/store.dart'; import 'package:bitree/school/network.dart'; @@ -118,11 +119,13 @@ class TestAppController extends AppController { List entries = const [], MemorySchoolStorage? schoolStorage, MemoryPreferences? preferences, + SystemPaletteSource? systemPaletteSource, }) : super( store: MemoryScheduleStore(schedules: schedules, selected: schedule), school: SchoolService(), schoolStorage: schoolStorage ?? MemorySchoolStorage(), preferences: preferences ?? MemoryPreferences(), + systemPaletteSource: systemPaletteSource, agenda: agenda ?? AgendaController(MemoryAgendaStore(entries)), academics: AcademicController(MemoryAcademicStore()), ) { diff --git a/test/system_palette_test.dart b/test/system_palette_test.dart new file mode 100644 index 0000000..68fb84a --- /dev/null +++ b/test/system_palette_test.dart @@ -0,0 +1,278 @@ +import 'dart:async'; + +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/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.setDarkCoursePalette(DarkCoursePalette.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.darkCoursePalette, DarkCoursePalette.mocha); + expect(second.developerMode, isFalse); + expect(second.widgetUseSystemPalette, isFalse); + await second.setGlobalUseSystemPalette(false); + expect(second.systemPalette, isNull); + expect(second.darkCoursePalette, DarkCoursePalette.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, courses and agenda 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.setDarkCoursePalette(DarkCoursePalette.mocha); + 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, + ), + ]; + 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()!.darkPalette, + DarkCoursePalette.mocha, + ); + 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).fill, + isNot(originalAgenda[i].fill), + ); + } + final overdue = entries.last.copyWith(); + expect( + agendaTimelineColors( + context(), + overdue, + now.add(const Duration(days: 2)), + ).fill, + theme.colorScheme.errorContainer, + ); + 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(); + }, + ); + } +} From 717ae5efd3c1d18d14e854fd2f909751de366533 Mon Sep 17 00:00:00 2001 From: dongdigua Date: Thu, 29 Oct 2026 14:03:28 +0800 Subject: [PATCH 5/9] feat(ui): agenda dont use system palette --- docs/architecture.md | 2 +- lib/agenda/ui/timeline_colors.dart | 21 +---- lib/agenda/ui/timeline_entry.dart | 4 +- test/agenda_timeline_entry_test.dart | 119 +++++++++++++++++++++++++++ test/system_palette_test.dart | 17 ++-- 5 files changed, 136 insertions(+), 27 deletions(-) create mode 100644 test/agenda_timeline_entry_test.dart diff --git a/docs/architecture.md b/docs/architecture.md index bcb0da8..fd4cb85 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -57,7 +57,7 @@ DDL 绘制与布局分离:右端为 `dueAt`,左侧渐隐,屏幕宽度为 时间轴以视口中心时间和连续缩放比例计算坐标,不创建覆盖历史与未来的巨大滚动画布。默认一天一格,最多一小时一格;基准格宽为 48~84 逻辑像素。平移没有预设日期边界,只绘制可见区域附近的网格和条目。顶部工具栏只保留单行并排的「今天」按钮与自适应宽度的缩放滑条,不显示当前日期和常驻的「时间/格」文本,缩放比例仅保留无障碍语义描述。表头基准高度为 24 逻辑像素:不显示小时时,第一行显示日期、第二行居中显示星期;显示小时时,第一行将「日期 星期」作为一个整体居中,第二行显示小时。标签宽度测量包含完整星期与中间空格,固定在对应时间格的中上方,以完整文字绘制并在视口边缘自然裁切,不贴靠屏幕边缘或变成省略号。首个日程条距表头分隔线 12 逻辑像素,与日程条之间的轨道间距一致。工具栏不再包含问号帮助图标及其提示。当前时间线单独在前景绘制,覆盖日程条但不拦截交互,也不绘制圆点。条目单击复用日程详情弹层,编辑、删除、同步后由功能控制器通知刷新。 -仅在时间轴中,考试使用橙色,乐学 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% 透明),保留各自原色;普通活动的完成样式不变。列表和课表不使用此来源映射。 操作方式:拖动或普通滚轮横向浏览时间,双指捏合、`Ctrl + 滚轮` 或滑条连续缩放;上下拖动或 `Shift + 滚轮` 浏览泳道。时间轴隐藏显式与桌面自动添加的纵向滚动条,仍保留纵向滚动和惯性。单指或鼠标拖动释放后,使用 Flutter 的摩擦物理模拟继续滑行;缩放、新输入、隐藏视图或离开页面时停止惯性,触控板自身的惯性输入不额外叠加甩动。「今天」按钮回到今日居中。时间轴不包在完成状态的 `SwipePager` 内,避免横向时间导航触发状态切页。 diff --git a/lib/agenda/ui/timeline_colors.dart b/lib/agenda/ui/timeline_colors.dart index 141981f..9f1821c 100644 --- a/lib/agenda/ui/timeline_colors.dart +++ b/lib/agenda/ui/timeline_colors.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import '../../schedule/building_palette.dart'; -import '../../ui/system_palette.dart'; import '../deadline_source.dart'; import '../model.dart'; import '../physics_lab_deadlines.dart'; @@ -14,16 +13,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 => @@ -45,15 +34,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) { - final system = theme.extension()?.palette; - if (system != null) { - final colors = system.courseColors(palette.area, theme.brightness); - return ( - fill: colors.fill.withValues(alpha: fillAlpha), - text: colors.text.withValues(alpha: foregroundAlpha), - accent: colors.accent.withValues(alpha: foregroundAlpha), - ); - } + // Keep source colours recognizable even when the app uses system colours. final values = theme.brightness == Brightness.dark ? palette.dark : palette.light; diff --git a/lib/agenda/ui/timeline_entry.dart b/lib/agenda/ui/timeline_entry.dart index 52fec53..cc0775a 100644 --- a/lib/agenda/ui/timeline_entry.dart +++ b/lib/agenda/ui/timeline_entry.dart @@ -100,7 +100,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/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/system_palette_test.dart b/test/system_palette_test.dart index 68fb84a..63345ba 100644 --- a/test/system_palette_test.dart +++ b/test/system_palette_test.dart @@ -165,7 +165,7 @@ void main() { for (final brightness in Brightness.values) { testWidgets( - 'Global palette updates the app, courses and agenda and restores $brightness colours', + 'Global palette updates the app and courses, preserves agenda source colours and restores $brightness colours', (tester) async { final palette = testSystemPalette(); final controller = TestAppController( @@ -209,6 +209,13 @@ void main() { 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); @@ -237,8 +244,8 @@ void main() { expect(colors.fill, isNot(originalCourse.fill)); for (var i = 0; i < entries.length; i++) { expect( - agendaTimelineColors(context(), entries[i], now).fill, - isNot(originalAgenda[i].fill), + agendaTimelineColors(context(), entries[i], now), + originalAgenda[i], ); } final overdue = entries.last.copyWith(); @@ -247,8 +254,8 @@ void main() { context(), overdue, now.add(const Duration(days: 2)), - ).fill, - theme.colorScheme.errorContainer, + ), + originalAgenda.last, ); expect( agendaTimelineColors( From e3c07eb7271a4fc980c543f5bfe7706ca09e019b Mon Sep 17 00:00:00 2001 From: dongdigua Date: Thu, 29 Oct 2026 14:32:11 +0800 Subject: [PATCH 6/9] feat(ui): show agenda as primarycolor in home --- docs/architecture.md | 2 + lib/agenda/ui/calendar_block.dart | 14 ++-- test/calendar_block_test.dart | 113 ++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 test/calendar_block_test.dart diff --git a/docs/architecture.md b/docs/architecture.md index fd4cb85..05227cd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,6 +59,8 @@ DDL 绘制与布局分离:右端为 `dueAt`,左侧渐隐,屏幕宽度为 仅在时间轴中,考试使用橙色,乐学 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` 等低对比细分隔色。 + 操作方式:拖动或普通滚轮横向浏览时间,双指捏合、`Ctrl + 滚轮` 或滑条连续缩放;上下拖动或 `Shift + 滚轮` 浏览泳道。时间轴隐藏显式与桌面自动添加的纵向滚动条,仍保留纵向滚动和惯性。单指或鼠标拖动释放后,使用 Flutter 的摩擦物理模拟继续滑行;缩放、新输入、隐藏视图或离开页面时停止惯性,触控板自身的惯性输入不额外叠加甩动。「今天」按钮回到今日居中。时间轴不包在完成状态的 `SwipePager` 内,避免横向时间导航触发状态切页。 ## 空教室查询 diff --git a/lib/agenda/ui/calendar_block.dart b/lib/agenda/ui/calendar_block.dart index 6dbaf80..8a4e6ce 100644 --- a/lib/agenda/ui/calendar_block.dart +++ b/lib/agenda/ui/calendar_block.dart @@ -42,10 +42,12 @@ class CalendarBlockTile extends StatelessWidget { } final scheme = Theme.of(context).colorScheme; final exam = items.any((item) => item.entry?.kind == AgendaKind.exam); - final background = exam - ? scheme.errorContainer - : scheme.surfaceContainerLow; - final foreground = exam ? scheme.onErrorContainer : scheme.onSurface; + // Tone-based roles keep event blocks visible under the system palette too; + // the 1px primary outline, not the subtle container fill, carries contrast. + final background = exam ? scheme.errorContainer : scheme.primaryContainer; + final foreground = exam + ? scheme.onErrorContainer + : scheme.onPrimaryContainer; final summary = items .map((item) => '${item.kindLabel} ${item.title}\n${item.timeLabel}') .join('\n'); @@ -60,8 +62,8 @@ class CalendarBlockTile extends StatelessWidget { shape: RoundedRectangleBorder( borderRadius: AppCorners.compact, side: BorderSide( - color: exam ? scheme.error : scheme.outlineVariant, - width: exam ? 1 : 0.7, + color: exam ? scheme.error : scheme.primary, + width: 1, ), ), clipBehavior: Clip.antiAlias, diff --git a/test/calendar_block_test.dart b/test/calendar_block_test.dart new file mode 100644 index 0000000..ce78df0 --- /dev/null +++ b/test/calendar_block_test.dart @@ -0,0 +1,113 @@ +import 'dart:math' as math; + +import 'package:bitree/agenda/calendar.dart'; +import 'package:bitree/agenda/model.dart'; +import 'package:bitree/agenda/ui/calendar_block.dart'; +import 'package:bitree/ui/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.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() { + for (final brightness in Brightness.values) { + for (final system in [false, true]) { + testWidgets( + 'Home grid event blocks stay visible with ${system ? 'system' : 'app'} palette in $brightness', + (tester) async { + final theme = bitreeTheme( + brightness, + systemPalette: system ? testSystemPalette() : 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)); + expect(material.color, scheme.primaryContainer); + expect(title.style!.color, scheme.onPrimaryContainer); + expect(side().color, scheme.primary); + 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); + }, + ); + } + } +} From 3f477b5f8ed80181d012b39f15d82d94e54650ef Mon Sep 17 00:00:00 2001 From: dongdigua Date: Thu, 29 Oct 2026 15:19:45 +0800 Subject: [PATCH 7/9] feat(ui): agenda color in cat palette --- docs/architecture.md | 2 + lib/agenda/ui/calendar_block.dart | 28 ++- lib/agenda/ui/timeline_colors.dart | 10 + lib/core/platform/system_palette.dart | 1 - lib/ui/course_palette.dart | 20 ++ test/calendar_block_test.dart | 255 ++++++++++++++++++-------- test/support/test_app.dart | 4 +- 7 files changed, 228 insertions(+), 92 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 05227cd..78b1bbb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -61,6 +61,8 @@ DDL 绘制与布局分离:右端为 `dueAt`,左侧渐隐,屏幕宽度为 主页课表网格中的日程块不复用时间轴的楼宇来源色:普通活动使用 `primaryContainer` 背景、`onPrimaryContainer` 文字和 1px `primary` 边框,考试使用 `errorContainer`/`onErrorContainer`/`error`。浅色模式下容器色与页面底色天然接近,可见性由 tone 固定的边框和文字承担,因此开启全局系统调色板后仍能辨认;不使用 `outlineVariant` 等低对比细分隔色。 +关闭全局系统调色板且处于深色模式时,Mocha/Macchiato/Frappé 仅覆盖课程与日程块,不修改页面背景或全局 `ColorScheme`。主页普通日程块与时间轴中的普通活动、无平台来源的手动 DDL 统一使用 Catppuccin 官方 Surface 0/Text/Lavender,分别为 Mocha `#313244`/`#CDD6F4`/`#B4BEFE`、Macchiato `#363A4F`/`#CAD3F5`/`#B7BDF8`、Frappé `#414559`/`#C6D0F5`/`#BABBF1`。参考 [Catppuccin palette](https://github.com/catppuccin/palette/blob/main/palette.json) 与 [style guide](https://github.com/catppuccin/catppuccin/blob/main/docs/style-guide.md),Lavender 用作应用内日程块的强调边框。时间轴中的平台作业、物理实验与考试继续保留来源映射,逾期右侧条和主页考试沿用错误色;完成淡化规则不变。浅色模式或开启全局系统调色板时不应用 Cat 日程色。 + 操作方式:拖动或普通滚轮横向浏览时间,双指捏合、`Ctrl + 滚轮` 或滑条连续缩放;上下拖动或 `Shift + 滚轮` 浏览泳道。时间轴隐藏显式与桌面自动添加的纵向滚动条,仍保留纵向滚动和惯性。单指或鼠标拖动释放后,使用 Flutter 的摩擦物理模拟继续滑行;缩放、新输入、隐藏视图或离开页面时停止惯性,触控板自身的惯性输入不额外叠加甩动。「今天」按钮回到今日居中。时间轴不包在完成状态的 `SwipePager` 内,避免横向时间导航触发状态切页。 ## 空教室查询 diff --git a/lib/agenda/ui/calendar_block.dart b/lib/agenda/ui/calendar_block.dart index 8a4e6ce..54b7afe 100644 --- a/lib/agenda/ui/calendar_block.dart +++ b/lib/agenda/ui/calendar_block.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../app/controller.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'; @@ -40,14 +41,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); - // Tone-based roles keep event blocks visible under the system palette too; - // the 1px primary outline, not the subtle container fill, carries contrast. - final background = exam ? scheme.errorContainer : scheme.primaryContainer; + final palette = agendaPaletteColors(theme); + // The outline carries contrast for subtle fills in both palette modes. + final background = exam + ? scheme.errorContainer + : palette == null + ? scheme.primaryContainer + : Color(palette.fill); final foreground = exam ? scheme.onErrorContainer - : scheme.onPrimaryContainer; + : palette == null + ? scheme.onPrimaryContainer + : Color(palette.text); + final outline = exam + ? scheme.error + : palette == null + ? scheme.primary + : Color(palette.accent); final summary = items .map((item) => '${item.kindLabel} ${item.title}\n${item.timeLabel}') .join('\n'); @@ -61,10 +74,7 @@ class CalendarBlockTile extends StatelessWidget { color: background, shape: RoundedRectangleBorder( borderRadius: AppCorners.compact, - side: BorderSide( - color: exam ? scheme.error : scheme.primary, - width: 1, - ), + 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 9f1821c..c8bf0a1 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'; @@ -45,6 +46,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/core/platform/system_palette.dart b/lib/core/platform/system_palette.dart index 3b57c0d..ae11707 100644 --- a/lib/core/platform/system_palette.dart +++ b/lib/core/platform/system_palette.dart @@ -1,6 +1,5 @@ import 'dart:io'; -import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import '../../ui/system_palette.dart'; diff --git a/lib/ui/course_palette.dart b/lib/ui/course_palette.dart index d32ac7f..0301df4 100644 --- a/lib/ui/course_palette.dart +++ b/lib/ui/course_palette.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../schedule/building_palette.dart'; +import 'system_palette.dart'; enum DarkCoursePalette { standard('默认'), @@ -15,6 +16,15 @@ enum DarkCoursePalette { BuildingColorValues colorsFor(BuildingPalette building) => _colorsForArea(building.area) ?? building.dark; + /// Catppuccin Surface 0 / Text / Lavender for ordinary agenda blocks. + /// https://github.com/catppuccin/catppuccin/blob/main/docs/style-guide.md + BuildingColorValues? get agendaColors => switch (this) { + standard => null, + mocha => (fill: 0xFF313244, text: 0xFFCDD6F4, accent: 0xFFB4BEFE), + macchiato => (fill: 0xFF363A4F, text: 0xFFCAD3F5, accent: 0xFFB7BDF8), + frappe => (fill: 0xFF414559, text: 0xFFC6D0F5, accent: 0xFFBABBF1), + }; + /// Opaque colour pairs for native courses by area, plus a surface pair. /// An empty map restores the widget's default colours. Map> get widgetColors { @@ -86,6 +96,16 @@ enum DarkCoursePalette { } } +/// Resolve a block-only override without changing the app's ColorScheme. +/// System colours take priority; Catppuccin only applies in dark mode. +BuildingColorValues? agendaPaletteColors(ThemeData theme) { + if (theme.brightness != Brightness.dark || + theme.extension() != null) { + return null; + } + return theme.extension()?.darkPalette.agendaColors; +} + class CoursePaletteTheme extends ThemeExtension { const CoursePaletteTheme(this.darkPalette); diff --git a/test/calendar_block_test.dart b/test/calendar_block_test.dart index ce78df0..ca0af12 100644 --- a/test/calendar_block_test.dart +++ b/test/calendar_block_test.dart @@ -1,8 +1,12 @@ 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'; @@ -19,95 +23,188 @@ double _contrast(Color a, Color b) { } void main() { + test('Agenda colours match Catppuccin Surface 0, Text and Lavender', () { + expect(DarkCoursePalette.standard.agendaColors, isNull); + for (final (palette, fill, text, accent) in [ + (DarkCoursePalette.mocha, 0xFF313244, 0xFFCDD6F4, 0xFFB4BEFE), + (DarkCoursePalette.macchiato, 0xFF363A4F, 0xFFCAD3F5, 0xFFB7BDF8), + (DarkCoursePalette.frappe, 0xFF414559, 0xFFC6D0F5, 0xFFBABBF1), + ]) { + expect(palette.agendaColors, (fill: fill, text: text, accent: accent)); + } + }); + for (final brightness in Brightness.values) { for (final system in [false, true]) { - testWidgets( - 'Home grid event blocks stay visible with ${system ? 'system' : 'app'} palette in $brightness', - (tester) async { - final theme = bitreeTheme( - brightness, - systemPalette: system ? testSystemPalette() : 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)), - ); + for (final palette in DarkCoursePalette.values) { + testWidgets( + 'Home and timeline event blocks stay visible with ${palette.label}, system=$system, $brightness', + (tester) async { + final systemPalette = system ? testSystemPalette() : null; + final theme = bitreeTheme( + brightness, + darkCoursePalette: 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 cat = brightness == Brightness.dark && !system + ? palette.agendaColors + : 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, + 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(); - } + ); + 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; + 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)); - expect(material.color, scheme.primaryContainer); - expect(title.style!.color, scheme.onPrimaryContainer); - expect(side().color, scheme.primary); - 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(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); - }, - ); + 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/support/test_app.dart b/test/support/test_app.dart index 9c3da9a..a475231 100644 --- a/test/support/test_app.dart +++ b/test/support/test_app.dart @@ -5,7 +5,6 @@ import 'package:bitree/agenda/controller.dart'; import 'package:bitree/agenda/model.dart'; import 'package:bitree/agenda/store.dart'; import 'package:bitree/app/controller.dart'; -import 'package:bitree/core/platform/system_palette.dart'; import 'package:bitree/schedule/model.dart'; import 'package:bitree/schedule/store.dart'; import 'package:bitree/school/network.dart'; @@ -119,13 +118,12 @@ class TestAppController extends AppController { List entries = const [], MemorySchoolStorage? schoolStorage, MemoryPreferences? preferences, - SystemPaletteSource? systemPaletteSource, + super.systemPaletteSource, }) : super( store: MemoryScheduleStore(schedules: schedules, selected: schedule), school: SchoolService(), schoolStorage: schoolStorage ?? MemorySchoolStorage(), preferences: preferences ?? MemoryPreferences(), - systemPaletteSource: systemPaletteSource, agenda: agenda ?? AgendaController(MemoryAgendaStore(entries)), academics: AcademicController(MemoryAcademicStore()), ) { From d26ae65c627f3d0e1b6129edc612ec9e49d5afee Mon Sep 17 00:00:00 2001 From: dongdigua Date: Thu, 29 Oct 2026 17:52:48 +0800 Subject: [PATCH 8/9] feat(color): revolutionize color loading --- .../kotlin/dev/so1ve/bitree/BuildingColors.kt | 25 +- .../kotlin/dev/so1ve/bitree/MainActivity.kt | 5 +- assets/palettes/frappe.json | 13 + assets/palettes/macchiato.json | 13 + assets/palettes/mocha.json | 13 + docs/architecture.md | 4 +- docs/palettes.md | 39 +++ lib/app/controller.dart | 200 +++++++++++---- lib/app/ui/palette_settings.dart | 112 ++++++++ lib/app/ui/settings_screen.dart | 30 +-- lib/core/platform/desktop_widget.dart | 10 +- lib/main.dart | 3 +- lib/ui/course_palette.dart | 191 +++++++------- lib/ui/theme.dart | 18 +- pubspec.yaml | 1 + test/building_palette_test.dart | 57 +++-- test/calendar_block_test.dart | 34 ++- test/course_palette_test.dart | 239 ++++++++++++++++++ test/developer_settings_test.dart | 33 +-- test/support/course_palettes.dart | 24 ++ test/support/test_app.dart | 3 + test/system_palette_test.dart | 17 +- 22 files changed, 829 insertions(+), 255 deletions(-) create mode 100644 assets/palettes/frappe.json create mode 100644 assets/palettes/macchiato.json create mode 100644 assets/palettes/mocha.json create mode 100644 docs/palettes.md create mode 100644 lib/app/ui/palette_settings.dart create mode 100644 test/course_palette_test.dart create mode 100644 test/support/course_palettes.dart 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 5d9d7d3..810c5c2 100644 --- a/android/app/src/main/kotlin/dev/so1ve/bitree/BuildingColors.kt +++ b/android/app/src/main/kotlin/dev/so1ve/bitree/BuildingColors.kt @@ -18,25 +18,25 @@ internal object BuildingColors { private data class Table(val neutral: Palette, val rules: List) private var table: Table? = null - private var darkColors: Map? = null + private val customColors = mutableMapOf>() private const val PREFERENCES = "widget_appearance" - private const val DARK_COLORS = "darkCourseColors" + private fun colorsKey(dark: Boolean) = if (dark) "darkCourseColors" else "lightCourseColors" private val otherCampuses = Regex("中关村|西山|珠海|雄安|怀来") private val spacing = Regex("[\\s\\u00A0\\u3000·._\\-—–()()\\[\\]【】]") - fun saveDarkPalette(context: Context, colors: Map>) { + fun savePalette(context: Context, dark: Boolean, colors: Map>) { context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit() - .putString(DARK_COLORS, JSONObject(colors).toString()).apply() - darkColors = colors.mapValues { (_, values) -> + .putString(colorsKey(dark), JSONObject(colors).toString()).apply() + customColors[dark] = colors.mapValues { (_, values) -> values.map { it.toInt() }.toIntArray() } } - private fun readDarkPalette(context: Context): Map { - return darkColors ?: run { + private fun readPalette(context: Context, dark: Boolean): Map { + return customColors[dark] ?: run { try { val saved = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) - .getString(DARK_COLORS, "{}") ?: "{}" + .getString(colorsKey(dark), "{}") ?: "{}" val json = JSONObject(saved) json.keys().asSequence().associateWith { area -> val values = json.getJSONArray(area) @@ -46,14 +46,14 @@ internal object BuildingColors { } catch (error: Exception) { Log.w("BITree", "无法读取桌面组件的课程配色", error) emptyMap() - }.also { darkColors = it } + }.also { customColors[dark] = it } } } fun widgetBackground(context: Context): Int { val fallback = context.getColor(R.color.widget_surface) if (!isDark(context)) return fallback - return readDarkPalette(context)["surface"]?.firstOrNull() ?: fallback + return readPalette(context, true)["surface"]?.firstOrNull() ?: fallback } private fun isDark(context: Context): Boolean = @@ -81,8 +81,9 @@ internal object BuildingColors { val building = buildings.singleOrNull() val palette = building?.palette ?: data.neutral WidgetColors.course(context, building?.area)?.let { return it } - if (!isDark(context)) return palette.light - return readDarkPalette(context)[building?.area ?: "neutral"] ?: palette.dark + val dark = isDark(context) + return readPalette(context, dark)[building?.area ?: "neutral"] + ?: if (dark) palette.dark else palette.light } private fun read(context: Context): Table { 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 3c07592..9a88fc8 100644 --- a/android/app/src/main/kotlin/dev/so1ve/bitree/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/so1ve/bitree/MainActivity.kt @@ -36,8 +36,11 @@ class MainActivity : FlutterActivity() { 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.saveDarkPalette(this, it) + BuildingColors.savePalette(this, true, it) } call.argument("useSystemPalette")?.let { WidgetColors.saveUseSystemPalette(this, it) 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 78b1bbb..fd35d23 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -61,7 +61,9 @@ DDL 绘制与布局分离:右端为 `dueAt`,左侧渐隐,屏幕宽度为 主页课表网格中的日程块不复用时间轴的楼宇来源色:普通活动使用 `primaryContainer` 背景、`onPrimaryContainer` 文字和 1px `primary` 边框,考试使用 `errorContainer`/`onErrorContainer`/`error`。浅色模式下容器色与页面底色天然接近,可见性由 tone 固定的边框和文字承担,因此开启全局系统调色板后仍能辨认;不使用 `outlineVariant` 等低对比细分隔色。 -关闭全局系统调色板且处于深色模式时,Mocha/Macchiato/Frappé 仅覆盖课程与日程块,不修改页面背景或全局 `ColorScheme`。主页普通日程块与时间轴中的普通活动、无平台来源的手动 DDL 统一使用 Catppuccin 官方 Surface 0/Text/Lavender,分别为 Mocha `#313244`/`#CDD6F4`/`#B4BEFE`、Macchiato `#363A4F`/`#CAD3F5`/`#B7BDF8`、Frappé `#414559`/`#C6D0F5`/`#BABBF1`。参考 [Catppuccin palette](https://github.com/catppuccin/palette/blob/main/palette.json) 与 [style guide](https://github.com/catppuccin/catppuccin/blob/main/docs/style-guide.md),Lavender 用作应用内日程块的强调边框。时间轴中的平台作业、物理实验与考试继续保留来源映射,逾期右侧条和主页考试沿用错误色;完成淡化规则不变。浅色模式或开启全局系统调色板时不应用 Cat 日程色。 +关闭全局系统调色板时,所选 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 使用同一组颜色。时间轴中的平台作业、物理实验与考试继续保留来源映射,逾期右侧条和主页考试沿用错误色,完成淡化规则不变。系统调色板可用且已启用时优先于 JSON 配色。 + +开发者模式的「更多设置」提供浅色/深色调色板独立选择、JSON 文件导入以及自定义配色的删除按钮。导入成功后将完整配色内容复制到应用设置并应用到对应模式,不依赖原文件继续存在;校验或保存失败时保留原选择。删除正在使用的自定义配色会将对应模式恢复为默认。旧版本的 Cat 配色选择会自动恢复。Android 小组件接收浅色和深色课程的最终颜色,其系统课程配色开关仍独立控制;Android 12+ 的组件整体背景与文字继续使用系统颜色。文件格式与字段说明见 [调色板文件](palettes.md)。 操作方式:拖动或普通滚轮横向浏览时间,双指捏合、`Ctrl + 滚轮` 或滑条连续缩放;上下拖动或 `Shift + 滚轮` 浏览泳道。时间轴隐藏显式与桌面自动添加的纵向滚动条,仍保留纵向滚动和惯性。单指或鼠标拖动释放后,使用 Flutter 的摩擦物理模拟继续滑行;缩放、新输入、隐藏视图或离开页面时停止惯性,触控板自身的惯性输入不额外叠加甩动。「今天」按钮回到今日居中。时间轴不包在完成状态的 `SwipePager` 内,避免横向时间导航触发状态切页。 diff --git a/docs/palettes.md b/docs/palettes.md new file mode 100644 index 0000000..b8abe00 --- /dev/null +++ b/docs/palettes.md @@ -0,0 +1,39 @@ +# 调色板文件 + +在「关于 BITree」中连续点击图标五次开启开发者模式,然后在「更多设置」选择「导入调色板」。每个 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/lib/app/controller.dart b/lib/app/controller.dart index 884949b..53a9dea 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'; @@ -27,7 +29,9 @@ class AppController extends ChangeNotifier { required this.agenda, required this.academics, SystemPaletteSource? systemPaletteSource, - }) : _systemPaletteSource = systemPaletteSource ?? SystemPaletteSource(), + Future> Function()? coursePaletteLoader, + }) : _coursePaletteLoader = coursePaletteLoader ?? CoursePalette.loadBuiltins, + _systemPaletteSource = systemPaletteSource ?? SystemPaletteSource(), timetable = TimetableController(store) { this.school = SchoolController( service: school, @@ -53,7 +57,10 @@ class AppController extends ChangeNotifier { ThemeMode _themeMode = ThemeMode.system; bool _developerMode = false; bool _widgetUseSystemPalette = false; - DarkCoursePalette _darkCoursePalette = DarkCoursePalette.standard; + final Future> Function() _coursePaletteLoader; + final Map _palettes = {}; + final Map _paletteSelections = {}; + Future? _paletteSettingsLoad; Future _coursePaletteWrite = Future.value(); final SystemPaletteSource _systemPaletteSource; bool _globalUseSystemPalette = false; @@ -67,7 +74,17 @@ class AppController extends ChangeNotifier { ThemeMode get themeMode => _themeMode; bool get developerMode => _developerMode; bool get widgetUseSystemPalette => _widgetUseSystemPalette; - DarkCoursePalette get darkCoursePalette => _darkCoursePalette; + 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; @@ -82,16 +99,7 @@ class AppController extends ChangeNotifier { } catch (failure, stack) { AppDiagnostics.instance.report('读取外观设置', failure, stack); } - try { - final savedPalette = await _preferences.getString('darkCoursePalette'); - _darkCoursePalette = - DarkCoursePalette.values - .where((palette) => palette.name == savedPalette) - .firstOrNull ?? - DarkCoursePalette.standard; - } catch (failure, stack) { - AppDiagnostics.instance.report('读取深色课程配色', failure, stack); - } + await loadCoursePalettes(); try { _widgetUseSystemPalette = await _preferences.getBool('widgetUseSystemPalette') ?? false; @@ -107,7 +115,8 @@ class AppController extends ChangeNotifier { } await _refreshSystemPalette(); await updateDesktopWidget( - darkCourseColors: _darkCoursePalette.widgetColors, + lightCourseColors: paletteFor(Brightness.light)?.widgetColors ?? {}, + darkCourseColors: paletteFor(Brightness.dark)?.widgetColors ?? {}, useSystemPalette: _widgetUseSystemPalette, ); try { @@ -145,43 +154,146 @@ class AppController extends ChangeNotifier { notifyListeners(); } - Future setDarkCoursePalette(DarkCoursePalette value) { - _darkCoursePalette = value; - final useSystemPalette = _widgetUseSystemPalette; - notifyListeners(); - _coursePaletteWrite = _coursePaletteWrite.then((_) async { - try { - await _preferences.setString('darkCoursePalette', value.name); - await updateDesktopWidget( - darkCourseColors: value.widgetColors, - useSystemPalette: useSystemPalette, - ); - } catch (failure, stack) { - AppDiagnostics.instance.report('保存深色课程配色', failure, stack); + 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; + } + } } - }); - return _coursePaletteWrite; + } catch (failure, stack) { + AppDiagnostics.instance.report('读取调色板设置', failure, stack); + } + if (!_disposed) notifyListeners(); } - Future setWidgetUseSystemPalette(bool value) { - _widgetUseSystemPalette = value; - final palette = _darkCoursePalette; - notifyListeners(); - // Share the palette write queue so rapid changes reach native code in order. - _coursePaletteWrite = _coursePaletteWrite.then((_) async { - try { - await _preferences.setBool('widgetUseSystemPalette', value); - await updateDesktopWidget( - darkCourseColors: palette.widgetColors, - useSystemPalette: value, - ); - } catch (failure, stack) { - AppDiagnostics.instance.report('保存小组件调色板设置', failure, stack); + 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(); }); - return _coursePaletteWrite; } + 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; 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 e235464..8103248 100644 --- a/lib/app/ui/settings_screen.dart +++ b/lib/app/ui/settings_screen.dart @@ -10,11 +10,11 @@ import '../../school/ui/school_screen.dart'; import '../../ui/app_menu_anchor.dart'; import '../../ui/app_menu_trigger.dart'; import '../../ui/app_page.dart'; -import '../../ui/course_palette.dart'; import '../../ui/external_link.dart'; import '../../updates/version.dart'; import '../controller.dart'; import 'logs_screen.dart'; +import 'palette_settings.dart'; class SettingsScreen extends StatelessWidget { const SettingsScreen({ @@ -288,33 +288,7 @@ class SettingsScreen extends StatelessWidget { ]), if (controller.developerMode) section('更多设置', [ - LayoutBuilder( - builder: (context, constraints) => AppMenuAnchor( - menuWidth: constraints.maxWidth, - menuChildren: [ - for (final palette in DarkCoursePalette.values) - Semantics( - selected: controller.darkCoursePalette == palette, - child: AppMenuItemButton( - onPressed: () => - controller.setDarkCoursePalette(palette), - trailingIcon: controller.darkCoursePalette == palette - ? const Icon(Icons.check_rounded, size: 18) - : const SizedBox(width: 18), - child: Text(palette.label), - ), - ), - ], - builder: (context, menu, isOpen) => ListTile( - contentPadding: tilePadding, - leading: const Icon(Icons.palette_outlined), - title: const Text('深色模式调色板'), - subtitle: Text(controller.darkCoursePalette.label), - trailing: AppMenuArrow(isOpen: isOpen), - onTap: () => isOpen ? menu.close() : menu.open(), - ), - ), - ), + PaletteSettings(controller: controller), SwitchListTile( contentPadding: tilePadding, secondary: const Icon(Icons.auto_awesome_outlined), diff --git a/lib/core/platform/desktop_widget.dart b/lib/core/platform/desktop_widget.dart index 1cd64b7..71e302b 100644 --- a/lib/core/platform/desktop_widget.dart +++ b/lib/core/platform/desktop_widget.dart @@ -5,10 +5,11 @@ 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. Supply [darkCourseColors] -/// to persist a new palette, or an empty map to restore native default colours. +/// 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 { @@ -16,9 +17,12 @@ Future updateDesktopWidget({ try { await const MethodChannel('dev.so1ve.bitree/widget').invokeMethod( 'update', - darkCourseColors == null && useSystemPalette == null + lightCourseColors == null && + darkCourseColors == null && + useSystemPalette == null ? null : { + 'lightCourseColors': ?lightCourseColors, 'darkCourseColors': ?darkCourseColors, 'useSystemPalette': ?useSystemPalette, }, diff --git a/lib/main.dart b/lib/main.dart index 73c33ed..60808f7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -60,11 +60,12 @@ class BitreeApp extends StatelessWidget { debugShowCheckedModeBanner: false, theme: bitreeTheme( Brightness.light, + coursePalette: controller.paletteFor(Brightness.light), systemPalette: controller.systemPalette, ), darkTheme: bitreeTheme( Brightness.dark, - darkCoursePalette: controller.darkCoursePalette, + coursePalette: controller.paletteFor(Brightness.dark), systemPalette: controller.systemPalette, ), themeMode: controller.themeMode, diff --git a/lib/ui/course_palette.dart b/lib/ui/course_palette.dart index 0301df4..656d98b 100644 --- a/lib/ui/course_palette.dart +++ b/lib/ui/course_palette.dart @@ -1,119 +1,120 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../schedule/building_palette.dart'; import 'system_palette.dart'; -enum DarkCoursePalette { - standard('默认'), - mocha('Mocha'), - macchiato('Macchiato'), - frappe('Frappé'); - - const DarkCoursePalette(this.label); - - final String label; +/// 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); - BuildingColorValues colorsFor(BuildingPalette building) => - _colorsForArea(building.area) ?? building.dark; - - /// Catppuccin Surface 0 / Text / Lavender for ordinary agenda blocks. - /// https://github.com/catppuccin/catppuccin/blob/main/docs/style-guide.md - BuildingColorValues? get agendaColors => switch (this) { - standard => null, - mocha => (fill: 0xFF313244, text: 0xFFCDD6F4, accent: 0xFFB4BEFE), - macchiato => (fill: 0xFF363A4F, text: 0xFFCAD3F5, accent: 0xFFB7BDF8), - frappe => (fill: 0xFF414559, text: 0xFFC6D0F5, accent: 0xFFBABBF1), - }; + factory CoursePalette.parse(String source) { + final Object? json; + try { + json = jsonDecode(source); + } on FormatException { + throw const FormatException('配色文件不是有效的 JSON。'); + } + return CoursePalette.fromJson(json); + } - /// Opaque colour pairs for native courses by area, plus a surface pair. - /// An empty map restores the widget's default colours. - Map> get widgetColors { - final surface = _colorsForArea(null); - if (surface == null) return {}; - final result = >{ - 'surface': [surface.fill, surface.text], + 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。'), }; - for (final area in [...CampusArea.values, null]) { - final colors = _colorsForArea(area); - if (colors == null) continue; - result[area?.name ?? 'neutral'] = [ - Color.lerp(Color(colors.fill), Color(colors.accent), 0.24)!.toARGB32(), - colors.text, - ]; + 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 result; + return CoursePalette._(name.trim(), brightness, colors); } - BuildingColorValues? _colorsForArea(CampusArea? area) { - // Catppuccin's Base, Text and area accents. Keep the generated building - // table intact so both renderers can restore their default colours. - final colors = switch (this) { - standard => null, - mocha => ( - base: 0xFF1E1E2E, - text: 0xFFCDD6F4, - blue: 0xFF89B4FA, - green: 0xFFA6E3A1, - yellow: 0xFFF9E2AF, - mauve: 0xFFCBA6F7, - peach: 0xFFFAB387, - neutral: 0xFFBAC2DE, - ), - macchiato => ( - base: 0xFF24273A, - text: 0xFFCAD3F5, - blue: 0xFF8AADF4, - green: 0xFFA6DA95, - yellow: 0xFFEED49F, - mauve: 0xFFC6A0F6, - peach: 0xFFF5A97F, - neutral: 0xFFB8C0E0, - ), - frappe => ( - base: 0xFF303446, - text: 0xFFC6D0F5, - blue: 0xFF8CAAEE, - green: 0xFFA6D189, - yellow: 0xFFE5C890, - mauve: 0xFFCA9EE6, - peach: 0xFFEF9F76, - neutral: 0xFFB5BFE2, + 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'), ), - }; - if (colors == null) return null; - return ( - fill: colors.base, - text: colors.text, - accent: switch (area) { - CampusArea.northTeaching => colors.blue, - CampusArea.wencui => colors.green, - CampusArea.southScience => colors.yellow, - CampusArea.eastFacilities => colors.mauve, - CampusArea.southEngineering => colors.peach, - null => colors.neutral, - }, - ); - } + }; + + 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], + }; } -/// Resolve a block-only override without changing the app's ColorScheme. -/// System colours take priority; Catppuccin only applies in dark mode. BuildingColorValues? agendaPaletteColors(ThemeData theme) { - if (theme.brightness != Brightness.dark || - theme.extension() != null) { - return null; - } - return theme.extension()?.darkPalette.agendaColors; + if (theme.extension() != null) return null; + return theme.extension()?.palette?.agendaColors; } class CoursePaletteTheme extends ThemeExtension { - const CoursePaletteTheme(this.darkPalette); + const CoursePaletteTheme(this.palette); - final DarkCoursePalette darkPalette; + final CoursePalette? palette; @override - CoursePaletteTheme copyWith({DarkCoursePalette? darkPalette}) => - CoursePaletteTheme(darkPalette ?? this.darkPalette); + CoursePaletteTheme copyWith({CoursePalette? palette}) => + CoursePaletteTheme(palette ?? this.palette); @override CoursePaletteTheme lerp(CoursePaletteTheme? other, double t) => diff --git a/lib/ui/theme.dart b/lib/ui/theme.dart index cb4d320..d2df4a0 100644 --- a/lib/ui/theme.dart +++ b/lib/ui/theme.dart @@ -10,7 +10,7 @@ const copper = Color(0xFFA85E3E); ThemeData bitreeTheme( Brightness brightness, { - DarkCoursePalette darkCoursePalette = DarkCoursePalette.standard, + CoursePalette? coursePalette, SystemPalette? systemPalette, }) { final dark = brightness == Brightness.dark; @@ -60,7 +60,9 @@ ThemeData bitreeTheme( return ThemeData( useMaterial3: true, extensions: [ - CoursePaletteTheme(darkCoursePalette), + CoursePaletteTheme( + coursePalette?.brightness == brightness ? coursePalette : null, + ), if (systemPalette != null) SystemPaletteTheme(systemPalette), ], colorScheme: scheme, @@ -305,14 +307,12 @@ courseColors(BuildContext context, Course course) { elapsedFill: Color.lerp(colors.fill, colors.accent, dark ? 0.16 : 0.32)!, ); } - final darkPalette = - theme.extension()?.darkPalette ?? - DarkCoursePalette.standard; - final colors = dark ? darkPalette.colorsFor(palette) : palette.light; + 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 d08f257..be288da 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,3 +34,4 @@ flutter: assets: - assets/brand/bitree.png - assets/wencui/ + - assets/palettes/ diff --git a/test/building_palette_test.dart b/test/building_palette_test.dart index 81fc1ee..c8af2b3 100644 --- a/test/building_palette_test.dart +++ b/test/building_palette_test.dart @@ -9,6 +9,8 @@ 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 = { @@ -174,9 +176,14 @@ void main() { testWidgets( 'Optional palettes preserve surfaces and export matching opaque widget course colours', (tester) async { - for (final palette in DarkCoursePalette.values) { - final widgetColors = palette.widgetColors; - if (palette == DarkCoursePalette.standard) { + for (final palette in [ + null, + ...testPalettes().values, + lightTestPalette(), + ]) { + final paletteName = palette?.name; + final widgetColors = palette?.widgetColors ?? >{}; + if (palette == null) { expect(widgetColors, isEmpty); } else { expect( @@ -187,16 +194,16 @@ void main() { 'surface', ]), ); - expect(widgetColors['surface']!.first, switch (palette) { - DarkCoursePalette.mocha => 0xFF1E1E2E, - DarkCoursePalette.macchiato => 0xFF24273A, - DarkCoursePalette.frappe => 0xFF303446, - DarkCoursePalette.standard => throw StateError('default'), + 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, darkCoursePalette: palette); + final theme = bitreeTheme(brightness, coursePalette: palette); expect(theme.colorScheme, originalTheme.colorScheme); expect( theme.scaffoldBackgroundColor, @@ -224,19 +231,19 @@ void main() { room: building.name, ); final colors = courseColors(context, course); - if (brightness == Brightness.dark && - palette != DarkCoursePalette.standard) { + 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) { + 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 == DarkCoursePalette.standard) { + } else if (palette?.brightness != brightness) { expect( colors.fill, Color.lerp( @@ -250,25 +257,21 @@ void main() { } else { expect( colors.text, - Color(switch (palette) { - DarkCoursePalette.mocha => 0xFFCDD6F4, - DarkCoursePalette.macchiato => 0xFFCAD3F5, - DarkCoursePalette.frappe => 0xFFC6D0F5, - DarkCoursePalette.standard => throw StateError( - 'default', - ), + Color(switch (paletteName) { + 'Mocha' => 0xFFCDD6F4, + 'Macchiato' => 0xFFCAD3F5, + 'Frappé' => 0xFFC6D0F5, + _ => 0xFF242424, }), ); if (building.area == CampusArea.wencui) { expect( colors.accent, - Color(switch (palette) { - DarkCoursePalette.mocha => 0xFFA6E3A1, - DarkCoursePalette.macchiato => 0xFFA6DA95, - DarkCoursePalette.frappe => 0xFFA6D189, - DarkCoursePalette.standard => throw StateError( - 'default', - ), + Color(switch (paletteName) { + 'Mocha' => 0xFFA6E3A1, + 'Macchiato' => 0xFFA6DA95, + 'Frappé' => 0xFFA6D189, + _ => 0xFF336633, }), ); } diff --git a/test/calendar_block_test.dart b/test/calendar_block_test.dart index ca0af12..de5bda1 100644 --- a/test/calendar_block_test.dart +++ b/test/calendar_block_test.dart @@ -11,6 +11,7 @@ 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'; @@ -23,27 +24,35 @@ double _contrast(Color a, Color b) { } void main() { - test('Agenda colours match Catppuccin Surface 0, Text and Lavender', () { - expect(DarkCoursePalette.standard.agendaColors, isNull); - for (final (palette, fill, text, accent) in [ - (DarkCoursePalette.mocha, 0xFF313244, 0xFFCDD6F4, 0xFFB4BEFE), - (DarkCoursePalette.macchiato, 0xFF363A4F, 0xFFCAD3F5, 0xFFB7BDF8), - (DarkCoursePalette.frappe, 0xFF414559, 0xFFC6D0F5, 0xFFBABBF1), + 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(palette.agendaColors, (fill: fill, text: text, accent: accent)); + 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 DarkCoursePalette.values) { + for (final palette in [ + null, + ...palettes.values, + lightTestPalette(), + ]) { testWidgets( - 'Home and timeline event blocks stay visible with ${palette.label}, system=$system, $brightness', + '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, - darkCoursePalette: palette, + coursePalette: palette, systemPalette: systemPalette, ); final original = bitreeTheme( @@ -56,8 +65,9 @@ void main() { original.scaffoldBackgroundColor, ); expect(theme.textTheme, original.textTheme); - final cat = brightness == Brightness.dark && !system - ? palette.agendaColors + final paletteColors = palette?.agendaColors; + final cat = brightness == palette?.brightness && !system + ? paletteColors : null; final scheme = theme.colorScheme; final controller = TestAppController(); diff --git a/test/course_palette_test.dart b/test/course_palette_test.dart new file mode 100644 index 0000000..afeee87 --- /dev/null +++ b/test/course_palette_test.dart @@ -0,0 +1,239 @@ +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 { + bool fail = false; + + @override + Future setString(String key, String value) async { + if (fail && 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 { + final preferences = _FailingPalettePreferences(); + final controller = TestAppController(preferences: preferences); + await controller.setCoursePalette(Brightness.dark, 'mocha'); + final saved = await preferences.getString('coursePalettes'); + await expectLater( + controller.importCoursePalette('{"name":"broken"}'), + throwsFormatException, + ); + preferences.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); + preferences.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 index 8472003..e5db868 100644 --- a/test/developer_settings_test.dart +++ b/test/developer_settings_test.dart @@ -16,25 +16,25 @@ void main() { final first = TestAppController(preferences: preferences); await first.initialize(); expect(first.developerMode, isFalse); - expect(first.darkCoursePalette, DarkCoursePalette.standard); + expect(first.paletteIdFor(Brightness.dark), isNull); first.unlockDeveloperMode(); expect(first.developerMode, isTrue); final writes = [ - first.setDarkCoursePalette(DarkCoursePalette.mocha), - first.setDarkCoursePalette(DarkCoursePalette.macchiato), - first.setDarkCoursePalette(DarkCoursePalette.frappe), + 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.darkCoursePalette, DarkCoursePalette.frappe); - await second.setDarkCoursePalette(DarkCoursePalette.standard); + expect(second.paletteIdFor(Brightness.dark), 'frappe'); + await second.setCoursePalette(Brightness.dark, null); final third = TestAppController(preferences: preferences); await third.initialize(); - expect(third.darkCoursePalette, DarkCoursePalette.standard); + expect(third.paletteFor(Brightness.dark), isNull); }, ); @@ -49,22 +49,22 @@ void main() { first.setWidgetUseSystemPalette(true), first.setWidgetUseSystemPalette(false), first.setWidgetUseSystemPalette(true), - first.setDarkCoursePalette(DarkCoursePalette.mocha), + first.setCoursePalette(Brightness.dark, 'mocha'), ]); final second = TestAppController(preferences: preferences); await second.initialize(); expect(second.widgetUseSystemPalette, isTrue); - expect(second.darkCoursePalette, DarkCoursePalette.mocha); + expect(second.paletteIdFor(Brightness.dark), 'mocha'); expect(second.developerMode, isFalse); - await second.setDarkCoursePalette(DarkCoursePalette.standard); + 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.darkCoursePalette, DarkCoursePalette.standard); + expect(third.paletteFor(Brightness.dark), isNull); }, ); @@ -73,7 +73,7 @@ void main() { await preferences.setString('darkCoursePalette', 'retired-palette'); final controller = TestAppController(preferences: preferences); await controller.initialize(); - expect(controller.darkCoursePalette, DarkCoursePalette.standard); + expect(controller.paletteFor(Brightness.dark), isNull); expect(controller.developerMode, isFalse); }); @@ -94,6 +94,7 @@ void main() { 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( @@ -141,7 +142,7 @@ void main() { -200, scrollable: scrollable, ); - expect(find.text('默认'), findsOneWidget); + expect(find.text('默认'), findsNWidgets(2)); final systemPaletteSwitch = find.widgetWithText( SwitchListTile, '小组件使用系统调色板', @@ -174,12 +175,12 @@ void main() { await tester.tap(find.widgetWithText(AppMenuItemButton, 'Mocha')); await tester.pumpAndSettle(); expect(find.text('Mocha'), findsOneWidget); - expect(controller.darkCoursePalette, DarkCoursePalette.mocha); + expect(controller.paletteIdFor(Brightness.dark), 'mocha'); expect(controller.widgetUseSystemPalette, isTrue); final theme = Theme.of(tester.element(find.byType(SettingsScreen))); expect( - theme.extension()!.darkPalette, - DarkCoursePalette.mocha, + theme.extension()!.palette, + same(controller.paletteFor(Brightness.dark)), ); expect(theme.colorScheme.surface, const Color(0xFF171717)); expect(theme.colorScheme.surfaceContainer, const Color(0xFF282828)); 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/test_app.dart b/test/support/test_app.dart index a475231..026c1a8 100644 --- a/test/support/test_app.dart +++ b/test/support/test_app.dart @@ -14,6 +14,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 [], @@ -120,6 +122,7 @@ class TestAppController extends AppController { MemoryPreferences? preferences, super.systemPaletteSource, }) : super( + coursePaletteLoader: () async => testPalettes(), store: MemoryScheduleStore(schedules: schedules, selected: schedule), school: SchoolService(), schoolStorage: schoolStorage ?? MemorySchoolStorage(), diff --git a/test/system_palette_test.dart b/test/system_palette_test.dart index 63345ba..8ee48d6 100644 --- a/test/system_palette_test.dart +++ b/test/system_palette_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:bitree/agenda/deadline_source.dart'; import 'package:bitree/agenda/model.dart'; @@ -13,6 +14,7 @@ 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'; @@ -65,7 +67,7 @@ void main() { expect(first.globalUseSystemPalette, isFalse); expect(first.systemPalette, isNull); expect(source.reads, 0); - await first.setDarkCoursePalette(DarkCoursePalette.mocha); + await first.setCoursePalette(Brightness.dark, 'mocha'); await Future.wait([ first.setGlobalUseSystemPalette(true), first.setGlobalUseSystemPalette(false), @@ -81,12 +83,12 @@ void main() { await second.initialize(); expect(second.globalUseSystemPalette, isTrue); expect(second.systemPalette, same(source.palette)); - expect(second.darkCoursePalette, DarkCoursePalette.mocha); + expect(second.paletteIdFor(Brightness.dark), 'mocha'); expect(second.developerMode, isFalse); expect(second.widgetUseSystemPalette, isFalse); await second.setGlobalUseSystemPalette(false); expect(second.systemPalette, isNull); - expect(second.darkCoursePalette, DarkCoursePalette.mocha); + expect(second.paletteIdFor(Brightness.dark), 'mocha'); final third = TestAppController(preferences: preferences); await third.initialize(); @@ -174,7 +176,10 @@ void main() { await controller.setTheme( brightness == Brightness.dark ? ThemeMode.dark : ThemeMode.light, ); - await controller.setDarkCoursePalette(DarkCoursePalette.mocha); + await controller.setCoursePalette(Brightness.dark, 'mocha'); + await controller.importCoursePalette( + jsonEncode(lightTestPalette().toJson()), + ); await tester.pumpWidget(BitreeApp(controller: controller)); await tester.tap( find.descendant( @@ -233,8 +238,8 @@ void main() { isNot(originalTheme.scaffoldBackgroundColor), ); expect( - theme.extension()!.darkPalette, - DarkCoursePalette.mocha, + theme.extension()!.palette, + controller.paletteFor(brightness), ); final colors = courseColors(context(), course); expect( From 805058b8794f6f7746e7e417a16a99f151e36021 Mon Sep 17 00:00:00 2001 From: dongdigua Date: Thu, 29 Oct 2026 18:18:21 +0800 Subject: [PATCH 9/9] feat(ui): appearance setting --- docs/architecture.md | 2 +- docs/palettes.md | 2 +- docs/ui-design.md | 9 +- lib/app/ui/appearance_screen.dart | 66 ++++++++++ lib/app/ui/settings_screen.dart | 52 +++----- test/appearance_settings_test.dart | 122 +++++++++++++++++++ test/course_palette_test.dart | 14 ++- test/developer_settings_test.dart | 186 ++++++++++++----------------- 8 files changed, 296 insertions(+), 157 deletions(-) create mode 100644 lib/app/ui/appearance_screen.dart create mode 100644 test/appearance_settings_test.dart diff --git a/docs/architecture.md b/docs/architecture.md index fd35d23..50f9663 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -63,7 +63,7 @@ DDL 绘制与布局分离:右端为 `dueAt`,左侧渐隐,屏幕宽度为 关闭全局系统调色板时,所选 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 使用同一组颜色。时间轴中的平台作业、物理实验与考试继续保留来源映射,逾期右侧条和主页考试沿用错误色,完成淡化规则不变。系统调色板可用且已启用时优先于 JSON 配色。 -开发者模式的「更多设置」提供浅色/深色调色板独立选择、JSON 文件导入以及自定义配色的删除按钮。导入成功后将完整配色内容复制到应用设置并应用到对应模式,不依赖原文件继续存在;校验或保存失败时保留原选择。删除正在使用的自定义配色会将对应模式恢复为默认。旧版本的 Cat 配色选择会自动恢复。Android 小组件接收浅色和深色课程的最终颜色,其系统课程配色开关仍独立控制;Android 12+ 的组件整体背景与文字继续使用系统颜色。文件格式与字段说明见 [调色板文件](palettes.md)。 +设置的「外观」二级页面提供明暗模式切换、浅色/深色调色板独立选择、JSON 文件导入、自定义配色的删除按钮及系统调色板开关,所有选项均可直接使用。关于弹窗中的五次连击仍可开启本次运行的开发者模式,但目前不影响任何功能。导入成功后将完整配色内容复制到应用设置并应用到对应模式,不依赖原文件继续存在;校验或保存失败时保留原选择。删除正在使用的自定义配色会将对应模式恢复为默认。旧版本的 Cat 配色选择会自动恢复。Android 小组件接收浅色和深色课程的最终颜色,其系统课程配色开关仍独立控制;Android 12+ 的组件整体背景与文字继续使用系统颜色。文件格式与字段说明见 [调色板文件](palettes.md)。 操作方式:拖动或普通滚轮横向浏览时间,双指捏合、`Ctrl + 滚轮` 或滑条连续缩放;上下拖动或 `Shift + 滚轮` 浏览泳道。时间轴隐藏显式与桌面自动添加的纵向滚动条,仍保留纵向滚动和惯性。单指或鼠标拖动释放后,使用 Flutter 的摩擦物理模拟继续滑行;缩放、新输入、隐藏视图或离开页面时停止惯性,触控板自身的惯性输入不额外叠加甩动。「今天」按钮回到今日居中。时间轴不包在完成状态的 `SwipePager` 内,避免横向时间导航触发状态切页。 diff --git a/docs/palettes.md b/docs/palettes.md index b8abe00..7dedaf6 100644 --- a/docs/palettes.md +++ b/docs/palettes.md @@ -1,6 +1,6 @@ # 调色板文件 -在「关于 BITree」中连续点击图标五次开启开发者模式,然后在「更多设置」选择「导入调色板」。每个 UTF-8 JSON 文件定义一个适用于浅色或深色模式的配色: +在「设置 → 外观」中选择「导入调色板」。每个 UTF-8 JSON 文件定义一个适用于浅色或深色模式的配色: ```json { diff --git a/docs/ui-design.md b/docs/ui-design.md index 112da3c..2005500 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -50,16 +50,17 @@ 深色课表将楼栋底色向同色系强调色提亮,课程块、地点文字与进度填充均使用 不透明色。当天列使用中性表面色,避免铜色罩染;日程时间轴沿用原有配色和渐变。 -在设置的“关于 BITree”弹窗中连续点击图标 5 次(相邻点击不超过 2 秒), -可解锁本次运行的开发者模式,并显示“更多设置”。开发者模式不持久化; -深色模式调色板选项会独立保存,重启后仍应用已选配色。 +设置首页提供“外观”入口,明暗模式、浅色/深色调色板、配色导入与删除以及系统调色板 +开关统一位于该二级页面,均可直接使用。在“关于 BITree”弹窗中连续点击图标 5 次 +(相邻点击不超过 2 秒)仍可解锁本次运行的开发者模式;该状态不持久化,目前不影响功能。 +调色板选项独立保存,重启后仍应用已选配色。 默认选项保留原有楼栋配色;Mocha、Macchiato、Frappé 使用 [Catppuccin](https://github.com/catppuccin/palette) 的 Base、Text 和片区强调色, 依次以蓝、绿、黄、紫、桃色对应北部教学、文萃、南部理科、东部设施和南部工科, 未知地点使用中性色。底色向强调色混合 24%,已上时段再混合 16%,次要文字 从底色向正文色混合 80%。这些选项作用于应用和 Android 小组件中的深色课程块。 Android 12 及以上,小组件背景、标题、次要文字和主题强调色始终读取系统动态色, -不受课程调色板或开关影响。开发者模式中的“小组件使用系统调色板”默认关闭, +不受课程调色板或开关影响。“外观”中的“小组件使用系统调色板”默认关闭, 关闭时课程沿用自选调色板;开启时仅小组件课程改用系统色,应用内课程保持原样。 系统课程色按片区使用三组强调色,并通过明度区分同组片区;未知地点使用中性色。 Android 12 以下保留原有背景和课程配色,深色背景可回退到所选 Catppuccin 的 Base。 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/settings_screen.dart b/lib/app/ui/settings_screen.dart index 8103248..b372caf 100644 --- a/lib/app/ui/settings_screen.dart +++ b/lib/app/ui/settings_screen.dart @@ -13,8 +13,8 @@ import '../../ui/app_page.dart'; import '../../ui/external_link.dart'; import '../../updates/version.dart'; import '../controller.dart'; +import 'appearance_screen.dart'; import 'logs_screen.dart'; -import 'palette_settings.dart'; class SettingsScreen extends StatelessWidget { const SettingsScreen({ @@ -269,41 +269,25 @@ class SettingsScreen extends StatelessWidget { padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), children: [ SchoolAccount(controller: controller), - section('外观', [ - SegmentedButton( - expandedInsets: EdgeInsets.zero, - showSelectedIcon: false, - segments: const [ - ButtonSegment( - value: ThemeMode.system, - label: Text('跟随系统', maxLines: 2, textAlign: TextAlign.center), + 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), ), - ButtonSegment(value: ThemeMode.light, label: Text('浅色')), - ButtonSegment(value: ThemeMode.dark, label: Text('深色')), - ], - selected: {controller.themeMode}, - onSelectionChanged: (selection) => - controller.setTheme(selection.single), - ), - ]), - if (controller.developerMode) - section('更多设置', [ - PaletteSettings(controller: controller), - SwitchListTile( - contentPadding: tilePadding, - secondary: const Icon(Icons.auto_awesome_outlined), - title: const Text('小组件使用系统调色板'), - value: controller.widgetUseSystemPalette, - onChanged: controller.setWidgetUseSystemPalette, ), - SwitchListTile( - contentPadding: tilePadding, - secondary: const Icon(Icons.color_lens_outlined), - title: const Text('全局系统调色板(实验性)'), - value: controller.globalUseSystemPalette, - onChanged: controller.setGlobalUseSystemPalette, - ), - ]), + ), + ), if (controller.timetable.schedule != null) section('本地课表', [ LayoutBuilder( 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/course_palette_test.dart b/test/course_palette_test.dart index afeee87..21bf58a 100644 --- a/test/course_palette_test.dart +++ b/test/course_palette_test.dart @@ -10,12 +10,15 @@ import 'support/course_palettes.dart'; import 'support/test_app.dart'; class _FailingPalettePreferences extends MemoryPreferences { - bool fail = false; + _FailingPalettePreferences(this.shouldFail); + + final bool Function() shouldFail; @override Future setString(String key, String value) async { - if (fail && key == 'coursePalettes') + if (shouldFail() && key == 'coursePalettes') { throw StateError('Storage unavailable'); + } await super.setString(key, value); } } @@ -130,7 +133,8 @@ void main() { test( 'Invalid imports and failed saves preserve the current selection and catalog', () async { - final preferences = _FailingPalettePreferences(); + var fail = false; + final preferences = _FailingPalettePreferences(() => fail); final controller = TestAppController(preferences: preferences); await controller.setCoursePalette(Brightness.dark, 'mocha'); final saved = await preferences.getString('coursePalettes'); @@ -138,7 +142,7 @@ void main() { controller.importCoursePalette('{"name":"broken"}'), throwsFormatException, ); - preferences.fail = true; + fail = true; await expectLater( controller.importCoursePalette(jsonEncode(lightTestPalette().toJson())), throwsStateError, @@ -147,7 +151,7 @@ void main() { expect(controller.paletteFor(Brightness.light), isNull); expect(controller.customPalettes, isEmpty); expect(await preferences.getString('coursePalettes'), saved); - preferences.fail = false; + fail = false; // A failed import must not poison subsequent writes. await controller.importCoursePalette( jsonEncode(lightTestPalette().toJson()), diff --git a/test/developer_settings_test.dart b/test/developer_settings_test.dart index e5db868..7690dc7 100644 --- a/test/developer_settings_test.dart +++ b/test/developer_settings_test.dart @@ -1,7 +1,5 @@ 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 'package:package_info_plus/package_info_plus.dart'; @@ -77,115 +75,79 @@ void main() { expect(controller.developerMode, isFalse); }); - testWidgets('Five consecutive About icon taps reveal the palette menu', ( - 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++) { + 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.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); - await tester.scrollUntilVisible( - find.text('深色模式调色板'), - -200, - scrollable: scrollable, - ); - expect(find.text('默认'), findsNWidgets(2)); - 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(SettingsScreen))); - expect( - theme.extension()!.palette, - same(controller.paletteFor(Brightness.dark)), - ); - expect(theme.colorScheme.surface, const Color(0xFF171717)); - expect(theme.colorScheme.surfaceContainer, const Color(0xFF282828)); - expect(tester.takeException(), isNull); - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(); - }); + 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(); + }, + ); }