diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index 5755e28..1a4e336 100644 --- a/.flutter-plugins-dependencies +++ b/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"file_picker","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\file_picker-11.0.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_js","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\flutter_js-0.8.7\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"package_info_plus","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\package_info_plus-8.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sentry_flutter","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\sentry_flutter-8.14.2\\\\","native_build":true,"dependencies":["package_info_plus"],"dev_dependency":false},{"name":"url_launcher_ios","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\url_launcher_ios-6.4.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"file_picker","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\file_picker-11.0.3\\\\","native_build":true,"dependencies":["flutter_plugin_android_lifecycle"],"dev_dependency":false},{"name":"flutter_js","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\flutter_js-0.8.7\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_plugin_android_lifecycle","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\flutter_plugin_android_lifecycle-2.0.35\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"package_info_plus","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\package_info_plus-8.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sentry_flutter","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\sentry_flutter-8.14.2\\\\","native_build":true,"dependencies":["package_info_plus"],"dev_dependency":false},{"name":"url_launcher_android","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\url_launcher_android-6.3.32\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"file_picker","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\file_picker-11.0.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_js","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\flutter_js-0.8.7\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"package_info_plus","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\package_info_plus-8.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sentry_flutter","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\sentry_flutter-8.14.2\\\\","native_build":true,"dependencies":["package_info_plus"],"dev_dependency":false},{"name":"tray_manager","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\tray_manager-0.5.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_macos","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\url_launcher_macos-3.2.5\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"file_picker","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\file_picker-11.0.3\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"flutter_js","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\flutter_js-0.8.7\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"package_info_plus","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\package_info_plus-8.3.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sentry_flutter","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\sentry_flutter-8.14.2\\\\","native_build":true,"dependencies":["package_info_plus"],"dev_dependency":false},{"name":"tray_manager","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\tray_manager-0.5.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_linux","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\url_launcher_linux-3.2.2\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"file_picker","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\file_picker-11.0.3\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"flutter_js","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\flutter_js-0.8.7\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"package_info_plus","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\package_info_plus-8.3.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sentry_flutter","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\sentry_flutter-8.14.2\\\\","native_build":true,"dependencies":["package_info_plus"],"dev_dependency":false},{"name":"tray_manager","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\tray_manager-0.5.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_windows","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\url_launcher_windows-3.1.5\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"web":[{"name":"file_picker","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\file_picker-11.0.3\\\\","dependencies":[],"dev_dependency":false},{"name":"package_info_plus","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\package_info_plus-8.3.1\\\\","dependencies":[],"dev_dependency":false},{"name":"sentry_flutter","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\sentry_flutter-8.14.2\\\\","dependencies":["package_info_plus"],"dev_dependency":false},{"name":"url_launcher_web","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.flutter-io.cn\\\\url_launcher_web-2.4.3\\\\","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"file_picker","dependencies":["flutter_plugin_android_lifecycle"]},{"name":"flutter_js","dependencies":[]},{"name":"flutter_plugin_android_lifecycle","dependencies":[]},{"name":"package_info_plus","dependencies":[]},{"name":"sentry_flutter","dependencies":["package_info_plus"]},{"name":"tray_manager","dependencies":[]},{"name":"url_launcher","dependencies":["url_launcher_android","url_launcher_ios","url_launcher_linux","url_launcher_macos","url_launcher_web","url_launcher_windows"]},{"name":"url_launcher_android","dependencies":[]},{"name":"url_launcher_ios","dependencies":[]},{"name":"url_launcher_linux","dependencies":[]},{"name":"url_launcher_macos","dependencies":[]},{"name":"url_launcher_web","dependencies":[]},{"name":"url_launcher_windows","dependencies":[]}],"date_created":"2026-08-22 15:19:21.474936","version":"3.44.9","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"file_picker","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\file_picker-11.0.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_js","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_js-0.8.7\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"package_info_plus","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\package_info_plus-8.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sentry_flutter","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sentry_flutter-8.14.2\\\\","native_build":true,"dependencies":["package_info_plus"],"dev_dependency":false},{"name":"url_launcher_ios","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_ios-6.4.2\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"file_picker","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\file_picker-11.0.3\\\\","native_build":true,"dependencies":["flutter_plugin_android_lifecycle"],"dev_dependency":false},{"name":"flutter_js","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_js-0.8.7\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_plugin_android_lifecycle","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_plugin_android_lifecycle-2.0.35\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"package_info_plus","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\package_info_plus-8.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sentry_flutter","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sentry_flutter-8.14.2\\\\","native_build":true,"dependencies":["package_info_plus"],"dev_dependency":false},{"name":"url_launcher_android","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_android-6.3.33\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"file_picker","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\file_picker-11.0.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_js","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_js-0.8.7\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"package_info_plus","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\package_info_plus-8.3.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sentry_flutter","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sentry_flutter-8.14.2\\\\","native_build":true,"dependencies":["package_info_plus"],"dev_dependency":false},{"name":"tray_manager","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\tray_manager-0.5.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_macos","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_macos-3.2.6\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"file_picker","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\file_picker-11.0.3\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"flutter_js","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_js-0.8.7\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"package_info_plus","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\package_info_plus-8.3.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sentry_flutter","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sentry_flutter-8.14.2\\\\","native_build":true,"dependencies":["package_info_plus"],"dev_dependency":false},{"name":"tray_manager","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\tray_manager-0.5.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_linux","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_linux-3.2.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"file_picker","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\file_picker-11.0.3\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"flutter_js","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_js-0.8.7\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"package_info_plus","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\package_info_plus-8.3.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sentry_flutter","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sentry_flutter-8.14.2\\\\","native_build":true,"dependencies":["package_info_plus"],"dev_dependency":false},{"name":"tray_manager","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\tray_manager-0.5.3\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_windows","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_windows-3.1.6\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"web":[{"name":"file_picker","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\file_picker-11.0.3\\\\","dependencies":[],"dev_dependency":false},{"name":"package_info_plus","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\package_info_plus-8.3.1\\\\","dependencies":[],"dev_dependency":false},{"name":"sentry_flutter","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sentry_flutter-8.14.2\\\\","dependencies":["package_info_plus"],"dev_dependency":false},{"name":"url_launcher_web","path":"C:\\\\Users\\\\Seren\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_web-2.4.3\\\\","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"file_picker","dependencies":["flutter_plugin_android_lifecycle"]},{"name":"flutter_js","dependencies":[]},{"name":"flutter_plugin_android_lifecycle","dependencies":[]},{"name":"package_info_plus","dependencies":[]},{"name":"sentry_flutter","dependencies":["package_info_plus"]},{"name":"tray_manager","dependencies":[]},{"name":"url_launcher","dependencies":["url_launcher_android","url_launcher_ios","url_launcher_linux","url_launcher_macos","url_launcher_web","url_launcher_windows"]},{"name":"url_launcher_android","dependencies":[]},{"name":"url_launcher_ios","dependencies":[]},{"name":"url_launcher_linux","dependencies":[]},{"name":"url_launcher_macos","dependencies":[]},{"name":"url_launcher_web","dependencies":[]},{"name":"url_launcher_windows","dependencies":[]}],"date_created":"2026-10-01 12:39:02.203364","version":"3.47.5","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d29021..6bd848b 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -7,6 +7,10 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - windows/** include: package:flutter_lints/flutter.yaml linter: diff --git a/assets/plugins/calendar/index.js b/assets/plugins/calendar/index.js index ad2faf8..c90dc08 100644 --- a/assets/plugins/calendar/index.js +++ b/assets/plugins/calendar/index.js @@ -32,14 +32,25 @@ lw.register({ var startDow = first.getDay(); var lead = mondayFirst ? (startDow + 6) % 7 : startDow; - // 固定 6 行 42 格:月份切换时高度不跳变 + // 固定 6 行 42 格:月份切换时高度不跳变。 + // 日期格不能写死 36px:3x3/4x3 小卡片的网格行高可能只有 20~30px, + // 固定高度会触发 Flutter 的 RenderFlex overflow。按当前卡片的可用宽高 + // 计算格子尺寸,并让文字随格子一起收缩。 + var rows = ctx.grid.rows; + var extraHeight = rows >= 5 ? 88 : (rows >= 4 ? 26 : 0); + var gridHeight = Math.max(96, ctx.size.h - 56 - extraHeight); + var gridWidth = Math.max(84, ctx.size.w - 12); + var cellSize = Math.min(36, Math.min(gridWidth / 7 - 2, (gridHeight - 10) / 6)); + cellSize = Math.max(18, cellSize); + var compact = cellSize < 26; + var cells = []; for (var i = 0; i < 42; i++) { var d = new Date(viewYear, viewMonth, 1 - lead + i); var inMonth = d.getMonth() === viewMonth; var col = i % 7; cells.push(cell(d, inMonth, weekendCols.indexOf(col) >= 0, - showLunar, showFest)); + showLunar, showFest, cellSize, compact)); } var headCells = heads.map(function (h, i) { @@ -89,9 +100,8 @@ lw.register({ // 尺寸分档:格子越多,能承载的信息越多。 // 小档只放月历;中档加今日一行;大档在下方展开今日详情与近期节气/节日。 - var rows = ctx.grid.rows; - if (rows >= 4) body.children.splice(1, 0, todayStrip()); - if (rows >= 5) body.children.push({ t: 'divider' }, upcoming()); + if (rows >= 4 && !compact) body.children.splice(1, 0, todayStrip()); + if (rows >= 5 && !compact) body.children.push({ t: 'divider' }, upcoming()); ctx.render(body); } @@ -152,7 +162,7 @@ lw.register({ } // 一格:上面公历日,下面农历/节气/节日 - function cell(d, inMonth, weekend, showLunar, showFest) { + function cell(d, inMonth, weekend, showLunar, showFest, cellSize, compact) { var y = d.getFullYear(), m = d.getMonth() + 1, day = d.getDate(); var isToday = sameYMD(today, y, m - 1, day); @@ -185,12 +195,16 @@ lw.register({ dayOpacity = 0.9; } + // 紧凑格子只保留公历数字,避免副标题把行高再次撑开。 + if (compact) sub = ''; + var daySize = compact ? Math.max(10, cellSize * 0.48) : Math.min(15, cellSize * 0.42); + var subSize = Math.max(7, Math.min(9.5, cellSize * 0.27)); var inner = { - t: 'col', gap: 1, cross: 'center', main: 'center', children: [ - { t: 'text', v: '' + day, size: 15, weight: isToday ? 700 : 500, + t: 'col', gap: compact ? 0 : 1, cross: 'center', main: 'center', children: [ + { t: 'text', v: '' + day, size: daySize, weight: isToday ? 700 : 500, align: 'center', color: dayColor, opacity: dayOpacity }, sub - ? { t: 'text', v: sub, size: 9.5, align: 'center', maxLines: 1, + ? { t: 'text', v: sub, size: subSize, align: 'center', maxLines: 1, color: isToday ? '#0B1116' : subColor, opacity: isToday ? 0.85 : (inMonth ? 0.5 : 0.22) } : { t: 'box' } @@ -198,7 +212,7 @@ lw.register({ }; return { t: 'row', main: 'center', children: [ - { t: 'box', w: 36, h: 36, radius: 18, center: true, + { t: 'box', w: cellSize, h: cellSize, radius: cellSize / 2, center: true, bg: isToday ? ACCENT : (marked ? '#FFFFFF12' : null), child: inner } ] }; diff --git a/lib/config/config_binding.dart b/lib/config/config_binding.dart new file mode 100644 index 0000000..fd0aa72 --- /dev/null +++ b/lib/config/config_binding.dart @@ -0,0 +1,138 @@ +/// 配置注册与绑定的基础类型。 +/// +/// 绑定只负责内存中的配置目标、类型校验和变更记录,不直接执行文件 IO。 +library; + +enum ConfigScope { app, ai, card } + +enum ConfigValueType { boolean, number, text, select, json } + +class ConfigOption { + const ConfigOption({required this.value, required this.label}); + + final T value; + final String label; +} + +class ConfigSpec { + const ConfigSpec({ + required this.key, + required this.scope, + required this.type, + required this.read, + required this.write, + this.defaultValue, + this.min, + this.max, + this.step, + this.options = const [], + }); + + final String key; + final ConfigScope scope; + final ConfigValueType type; + final T Function() read; + final void Function(T value) write; + final T? defaultValue; + final double? min; + final double? max; + final double? step; + final List> options; +} + +class ConfigChange { + const ConfigChange({ + required this.key, + required this.scope, + required this.previous, + required this.value, + }); + + final String key; + final ConfigScope scope; + final Object? previous; + final Object? value; +} + +class ConfigValidationException implements Exception { + const ConfigValidationException(this.key, this.message); + + final String key; + final String message; + + @override + String toString() => 'Invalid configuration "$key": $message'; +} + +class ConfigBinding { + ConfigBinding(this.spec); + + final ConfigSpec spec; + + T get value => spec.read(); + + void set(T next) { + _validate(next); + spec.write(next); + } + + void _validate(T next) { + switch (spec.type) { + case ConfigValueType.boolean: + if (next is! bool) { + throw ConfigValidationException(spec.key, 'expected a boolean'); + } + case ConfigValueType.number: + if (next is! num) { + throw ConfigValidationException(spec.key, 'expected a number'); + } + final number = next.toDouble(); + if (spec.min != null && number < spec.min!) { + throw ConfigValidationException(spec.key, 'value is below the minimum'); + } + if (spec.max != null && number > spec.max!) { + throw ConfigValidationException(spec.key, 'value is above the maximum'); + } + if (spec.step != null && spec.step! > 0 && spec.min != null) { + final steps = (number - spec.min!) / spec.step!; + if ((steps - steps.round()).abs() > 0.000001) { + throw ConfigValidationException(spec.key, 'value does not match the step'); + } + } + case ConfigValueType.text: + if (next is! String) { + throw ConfigValidationException(spec.key, 'expected text'); + } + case ConfigValueType.select: + if (spec.options.isNotEmpty && + !spec.options.any((option) => option.value == next)) { + throw ConfigValidationException(spec.key, 'value is not an available option'); + } + case ConfigValueType.json: + break; + } + } +} + +class ConfigRegistry { + final Map> _bindings = {}; + + Iterable> get bindings => _bindings.values; + + ConfigBinding register(ConfigSpec spec) { + if (_bindings.containsKey(spec.key)) { + throw ArgumentError.value(spec.key, 'key', 'configuration key is already registered'); + } + final binding = ConfigBinding(spec); + _bindings[spec.key] = binding as ConfigBinding; + return binding; + } + + ConfigBinding? find(String key) { + final binding = _bindings[key]; + if (binding == null) return null; + return binding as ConfigBinding; + } + + void clear() => _bindings.clear(); +} diff --git a/lib/config/config_coordinator.dart b/lib/config/config_coordinator.dart new file mode 100644 index 0000000..7c16727 --- /dev/null +++ b/lib/config/config_coordinator.dart @@ -0,0 +1,74 @@ +/// 配置变更协调器。 +/// +/// 统一负责注册、内存更新、去抖持久化和宿主刷新通知;Store 仍是唯一文件持久化边界。 +library; + +import 'dart:async'; + +import '../store/store.dart'; +import 'config_binding.dart'; + +class ConfigCoordinator { + ConfigCoordinator({ + required this.store, + required this.state, + required this.onChanged, + this.onHotkeyChanged, + }); + + final Store store; + final AppState state; + final void Function() onChanged; + final void Function()? onHotkeyChanged; + final ConfigRegistry registry = ConfigRegistry(); + + final List _pending = []; + Timer? _notifyTimer; + + List get pendingChanges => List.unmodifiable(_pending); + bool get isDirty => _pending.isNotEmpty; + + ConfigBinding register(ConfigSpec spec) => registry.register(spec); + + bool set(ConfigBinding binding, T value, {bool hotkey = false}) { + final previous = binding.value; + if (previous == value) return false; + binding.set(value); + _pending.add(ConfigChange( + key: binding.spec.key, + scope: binding.spec.scope, + previous: previous, + value: value, + )); + store.save(state); + if (hotkey) onHotkeyChanged?.call(); + _notifyTimer?.cancel(); + _notifyTimer = Timer(const Duration(milliseconds: 260), () { + _notifyTimer = null; + if (isDirty) onChanged(); + }); + return true; + } + + void commit() { + store.save(state); + _notifyTimer?.cancel(); + _notifyTimer = Timer(const Duration(milliseconds: 260), () { + _notifyTimer = null; + onChanged(); + }); + } + + void markCommitted() => _pending.clear(); + + Future commitNow() async { + _notifyTimer?.cancel(); + await store.saveNow(state); + _pending.clear(); + } + + void dispose() { + _notifyTimer?.cancel(); + if (isDirty) onChanged(); + } +} diff --git a/lib/fonts/HarmonyOS_Sans_SC_Black.ttf b/lib/fonts/HarmonyOS_Sans_SC_Black.ttf deleted file mode 100644 index 824b3d4..0000000 Binary files a/lib/fonts/HarmonyOS_Sans_SC_Black.ttf and /dev/null differ diff --git a/lib/fonts/HarmonyOS_Sans_SC_Bold.ttf b/lib/fonts/HarmonyOS_Sans_SC_Bold.ttf deleted file mode 100644 index 5c925d1..0000000 Binary files a/lib/fonts/HarmonyOS_Sans_SC_Bold.ttf and /dev/null differ diff --git a/lib/fonts/HarmonyOS_Sans_SC_Light.ttf b/lib/fonts/HarmonyOS_Sans_SC_Light.ttf deleted file mode 100644 index 8cfbc9d..0000000 Binary files a/lib/fonts/HarmonyOS_Sans_SC_Light.ttf and /dev/null differ diff --git a/lib/fonts/HarmonyOS_Sans_SC_Medium.ttf b/lib/fonts/HarmonyOS_Sans_SC_Medium.ttf deleted file mode 100644 index 350f6a0..0000000 Binary files a/lib/fonts/HarmonyOS_Sans_SC_Medium.ttf and /dev/null differ diff --git a/lib/fonts/HarmonyOS_Sans_SC_Regular.ttf b/lib/fonts/HarmonyOS_Sans_SC_Regular.ttf deleted file mode 100644 index aff150a..0000000 Binary files a/lib/fonts/HarmonyOS_Sans_SC_Regular.ttf and /dev/null differ diff --git a/lib/fonts/HarmonyOS_Sans_SC_Thin.ttf b/lib/fonts/HarmonyOS_Sans_SC_Thin.ttf deleted file mode 100644 index 6a58b15..0000000 Binary files a/lib/fonts/HarmonyOS_Sans_SC_Thin.ttf and /dev/null differ diff --git a/lib/fonts/SourceHanSans-VF.ttf b/lib/fonts/SourceHanSans-VF.ttf new file mode 100644 index 0000000..f021d87 Binary files /dev/null and b/lib/fonts/SourceHanSans-VF.ttf differ diff --git a/lib/fonts/SourceHanSansHW-VF.ttf b/lib/fonts/SourceHanSansHW-VF.ttf new file mode 100644 index 0000000..ba19949 Binary files /dev/null and b/lib/fonts/SourceHanSansHW-VF.ttf differ diff --git a/lib/fonts/SourceHanSansHWSC-VF.ttf b/lib/fonts/SourceHanSansHWSC-VF.ttf new file mode 100644 index 0000000..ce56271 Binary files /dev/null and b/lib/fonts/SourceHanSansHWSC-VF.ttf differ diff --git a/lib/fonts/SourceHanSansHWTC-VF.ttf b/lib/fonts/SourceHanSansHWTC-VF.ttf new file mode 100644 index 0000000..e5a907d Binary files /dev/null and b/lib/fonts/SourceHanSansHWTC-VF.ttf differ diff --git a/lib/fonts/SourceHanSansSC-VF.ttf b/lib/fonts/SourceHanSansSC-VF.ttf new file mode 100644 index 0000000..c8496a2 Binary files /dev/null and b/lib/fonts/SourceHanSansSC-VF.ttf differ diff --git a/lib/fonts/SourceHanSansTC-VF.ttf b/lib/fonts/SourceHanSansTC-VF.ttf new file mode 100644 index 0000000..0bcd2ac Binary files /dev/null and b/lib/fonts/SourceHanSansTC-VF.ttf differ diff --git a/lib/main.dart b/lib/main.dart index 5f60ecf..ca9234b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -352,7 +352,7 @@ class VectraApp extends StatelessWidget { // 整条链路都必须透明:DWM 的逐像素 alpha 才有意义 color: Colors.transparent, theme: ThemeData( - brightness: Brightness.dark, useMaterial3: true, fontFamily: 'TsukushiBMaru'), + brightness: Brightness.dark, useMaterial3: true, fontFamily: 'SourceHanSansSC'), home: Scaffold( backgroundColor: Colors.transparent, body: AppRoot( diff --git a/lib/plugin/manifest.dart b/lib/plugin/manifest.dart index ab9f469..b1d9305 100644 --- a/lib/plugin/manifest.dart +++ b/lib/plugin/manifest.dart @@ -83,6 +83,35 @@ class PluginManifest { throw FormatException('defaultSize "$def" 不在 sizes 列表里'); } + final parsedSettings = >[]; + final keys = {}; + for (final item in (raw['settings'] as List? ?? const [])) { + if (item is! Map || item['key'] is! String) continue; + final setting = item.cast(); + final key = (setting['key'] as String).trim(); + if (key.isEmpty || !keys.add(key)) { + throw FormatException('settings 中存在重复或空 key: $key'); + } + final type = setting['type']; + if (type != null && + (type is! String || + !const ['boolean', 'number', 'text', 'select'].contains(type))) { + throw FormatException('设置项 $key 的 type 不合法'); + } + if (type == 'number') { + final min = setting['min']; + final max = setting['max']; + final step = setting['step']; + if (min is! num || max is! num || min > max || (step != null && (step is! num || step <= 0))) { + throw FormatException('设置项 $key 的 number 范围或 step 不合法'); + } + } + if (type == 'select' && setting['options'] is! List) { + throw FormatException('设置项 $key 缺少 select options'); + } + parsedSettings.add(setting); + } + return PluginManifest( id: id, name: req('name'), @@ -94,10 +123,7 @@ class PluginManifest { sizes: sizes, defaultSize: def, singleton: raw['singleton'] == true, - settings: [ - for (final s in (raw['settings'] as List? ?? const [])) - if (s is Map && s['key'] is String) s.cast() - ], + settings: parsedSettings, scripts: [ for (final s in (raw['scripts'] as List? ?? const [])) if (s is String) s diff --git a/lib/sidebar_main.dart b/lib/sidebar_main.dart index 723c2c1..0f606b9 100644 --- a/lib/sidebar_main.dart +++ b/lib/sidebar_main.dart @@ -53,7 +53,7 @@ class _SidebarApp extends StatelessWidget { return MaterialApp( debugShowCheckedModeBanner: false, color: Colors.transparent, - theme: ThemeData(fontFamily: 'TsukushiBMaru'), + theme: ThemeData(fontFamily: 'SourceHanSansSC'), home: const Scaffold( backgroundColor: Colors.transparent, body: _SidebarHost(), diff --git a/lib/ui/card_view.dart b/lib/ui/card_view.dart index a9bb4b7..79c40be 100644 --- a/lib/ui/card_view.dart +++ b/lib/ui/card_view.dart @@ -5,14 +5,61 @@ /// 而投影恰好落在区域之外,必然被裁掉。 library; +import 'dart:io'; import 'dart:ui' as ui; +import 'package:path/path.dart' as p; + import 'package:flutter/material.dart'; import '../model/card.dart'; import '../model/settings.dart'; import 'wallpaper.dart'; +/// 自定义背景图的平均亮度缓存(PluginImages 同款模式): +/// path → 0~1。解码是异步的,算完 revision +1 让卡片重建一次。 +/// 只缓存文件路径(一张卡一张图),量级可忽略。 +class _CardBg { + static final _lum = {}; + static final revision = ValueNotifier(0); + + static double? luminance(String path) => _lum[path]; + + static Future ensure(String path) async { + if (_lum.containsKey(path)) return; + _lum[path] = 0.5; // 占位:防同一张图并发重复解码 + try { + final bytes = await File(path).readAsBytes(); + final buffer = await ui.ImmutableBuffer.fromUint8List(bytes); + final codec = await ui.instantiateImageCodecWithSize( + buffer, + getTargetSize: (w, h) => + const ui.TargetImageSize(width: 32, height: 32), + ); + final frame = await codec.getNextFrame(); + codec.dispose(); + final data = await frame.image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ); + frame.image.dispose(); + var total = 0.0; + var n = 0; + if (data != null) { + final px = data.buffer.asUint8List(); + for (var i = 0; i + 3 < px.length; i += 4) { + total += + (0.2126 * px[i] + 0.7152 * px[i + 1] + 0.0722 * px[i + 2]) / 255; + n++; + } + } + _lum[path] = n == 0 ? 0.5 : total / n; + } catch (_) { + _lum[path] = 0.5; + } + revision.value++; + } +} + class CardView extends StatelessWidget { const CardView({ super.key, @@ -22,6 +69,7 @@ class CardView extends StatelessWidget { required this.height, required this.editing, required this.child, + required this.dataDir, }); final WidgetCard card; @@ -31,6 +79,25 @@ class CardView extends StatelessWidget { final bool editing; final Widget child; + /// 用户数据目录:自定义背景图存放在 /bg/ 下 + final String dataDir; + + /// 面板读取:某张自定义背景图的实测亮度(还没算出来时是 null)。 + /// 配合 [bgRevision] 做状态行刷新。 + static double? bgLuminance(String path) => _CardBg.luminance(path); + + /// 亮度缓存更新通知——面板的状态行监听它,算完自动刷新文字。 + static ValueNotifier get bgRevision => _CardBg.revision; + + /// 本卡的自定义背景图(card.settings['bgImage'],bg/ 下的文件名)。 + /// 没设置就是 null,走原来的云母/毛玻璃/纯色材质。 + File? get _bgImageFile { + final name = card.settings['bgImage'] as String?; + if (name == null || name.isEmpty) return null; + final f = File(p.join(dataDir, 'bg', name)); + return f.existsSync() ? f : null; + } + /// 卡片内容区四周的留白。插件运行时把"卡片外框尺寸"当成 ctx.size 报给 /// JS 之前,必须先扣掉这一圈,否则插件按尺寸算自己的布局时会拿到一个 /// 比实际可用空间大一圈的数字,导致内容底部溢出(见 surface.dart 里 @@ -63,6 +130,11 @@ class CardView extends StatelessWidget { /// /// 主题设置仍然管着设置窗口和 AI 侧边栏的明暗,只是不再插手卡片。 bool get _brightBackdrop { + final bgf = _bgImageFile; + if (bgf != null) { + final l = _CardBg.luminance(bgf.path); + if (l != null) return l > 0.5; + } if (settings.material == 'opaque') { return _baseColor.computeLuminance() > 0.5; } @@ -71,7 +143,8 @@ class CardView extends StatelessWidget { // "白底 + 云母 = 永远黑字看不清" 的根因:底色只决定色板的色相和明暗 // 档位,真正贴在屏幕上的是"色板 @ alpha 叠在壁纸上"的结果。 final a = _micaAlpha; - final composite = _micaBase.computeLuminance() * a + + final composite = + _micaBase.computeLuminance() * a + Wallpaper.brightness.value * (1 - a); return composite > 0.5; } @@ -176,107 +249,133 @@ class CardView extends StatelessWidget { // 这里**不再**监听 systemBrightness:卡片明暗只由壁纸和材质决定, // 系统深浅色切换不该让卡片重建(见 _brightBackdrop 的说明)。 return AnimatedBuilder( - animation: Listenable.merge( - [Wallpaper.brightness, Wallpaper.dominantColor, Wallpaper.dominantForeground]), - builder: (context, _) => SizedBox( - width: width, - height: height, - child: Stack( - children: [ - // 毛玻璃:把预先模糊好的壁纸按本卡片的屏幕位置反向偏移贴上, - // 再由外层 ClipRRect 裁成卡片形状 —— 看起来就是"透过卡片看到 - // 被磨砂的壁纸"。壁纸是静态的,所以这里每帧只是一次普通贴图。 - if (settings.material != 'opaque') - ClipRRect( - borderRadius: BorderRadius.circular(settings.cardRadius), - child: SizedBox( - width: width, - height: height, - child: ValueListenableBuilder( - valueListenable: Wallpaper.image, - builder: (context, img, _) { - if (img == null) return const SizedBox.shrink(); - final s = 1 / Wallpaper.scale; - final w = RawImage( - image: img, - width: img.width * s, - height: img.height * s, - fit: BoxFit.fill, - filterQuality: FilterQuality.low, - ); - // 云母不糊壁纸:壁纸压到半透明当色调底子,上面再盖那层色板。 - // 亚克力保持全透,那才是"透过玻璃看桌面"。 - // - // 透出强度跟着色板厚度走:色板越厚,下面这层露出来的越少, - // 留太多只是白白把浅色板拖灰。 - return Opacity( - opacity: settings.material == 'mica' - ? (1 - _micaAlpha).clamp(0.30, 0.55) - : 1.0, - child: OverflowBox( - alignment: Alignment.topLeft, - maxWidth: double.infinity, - maxHeight: double.infinity, - child: Transform.translate( - offset: Offset(-card.x, -card.y), - child: w, + animation: Listenable.merge([ + Wallpaper.brightness, + Wallpaper.dominantColor, + Wallpaper.dominantForeground, + _CardBg.revision, + ]), + builder: (context, _) { + final bgFile = _bgImageFile; + if (bgFile != null) _CardBg.ensure(bgFile.path); + return SizedBox( + width: width, + height: height, + child: Stack( + children: [ + // 毛玻璃:把预先模糊好的壁纸按本卡片的屏幕位置反向偏移贴上, + // 再由外层 ClipRRect 裁成卡片形状 —— 看起来就是"透过卡片看到 + // 被磨砂的壁纸"。壁纸是静态的,所以这里每帧只是一次普通贴图。 + if (bgFile != null) + // 自定义背景图:cover 裁满卡片,替代云母/毛玻璃整层 + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular(settings.cardRadius), + child: Image.file( + bgFile, + fit: BoxFit.cover, + gaplessPlayback: true, + filterQuality: FilterQuality.medium, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + ), + ) + else if (settings.material != 'opaque') + ClipRRect( + borderRadius: BorderRadius.circular(settings.cardRadius), + child: SizedBox( + width: width, + height: height, + child: ValueListenableBuilder( + valueListenable: Wallpaper.image, + builder: (context, img, _) { + if (img == null) return const SizedBox.shrink(); + final s = 1 / Wallpaper.scale; + final w = RawImage( + image: img, + width: img.width * s, + height: img.height * s, + fit: BoxFit.fill, + filterQuality: FilterQuality.low, + ); + // 云母不糊壁纸:壁纸压到半透明当色调底子,上面再盖那层色板。 + // 亚克力保持全透,那才是"透过玻璃看桌面"。 + // + // 透出强度跟着色板厚度走:色板越厚,下面这层露出来的越少, + // 留太多只是白白把浅色板拖灰。 + return Opacity( + opacity: settings.material == 'mica' + ? (1 - _micaAlpha).clamp(0.30, 0.55) + : 1.0, + child: OverflowBox( + alignment: Alignment.topLeft, + maxWidth: double.infinity, + maxHeight: double.infinity, + child: Transform.translate( + offset: Offset(-card.x, -card.y), + child: w, + ), + ), + ); + }, + ), + ), + ), + // 卡片本体 + ClipRRect( + borderRadius: BorderRadius.circular(settings.cardRadius), + child: Container( + width: width, + height: height, + decoration: BoxDecoration( + // 自定义背景图时图就是底,色板全部让位 + // 毛玻璃模式下底色只是一层染色,模糊的壁纸在它下面 + color: bgFile != null ? Colors.transparent : _fill, + // 整圈细边框,不加任何高光/渐变。 + // 这不是阴影:阴影要画在卡片外面,而窗口区域正好裁在卡片边界上, + // 画了也会被切掉,所以边框画在卡片内部。 + border: Border.all(color: _edge, width: 1), + borderRadius: BorderRadius.circular(settings.cardRadius), + ), + padding: contentPadding, + child: Stack( + children: [ + Positioned.fill( + child: DefaultTextStyle( + style: TextStyle( + color: _foreground, + fontSize: 13, + // 这里的 DefaultTextStyle 是替换式的(不带 merge),会把 + // 主题里传下来的 fontFamily 冲掉,必须显式带上全局字体 + fontFamily: 'SourceHanSansSC', + decoration: TextDecoration.none, + ), + child: child, ), ), - ); - }, + ], + ), ), ), - ), - // 卡片本体 - ClipRRect( - borderRadius: BorderRadius.circular(settings.cardRadius), - child: Container( - width: width, - height: height, - decoration: BoxDecoration( - // 毛玻璃模式下底色只是一层染色,模糊的壁纸在它下面 - color: _fill, - // 整圈细边框,不加任何高光/渐变。 - // 这不是阴影:阴影要画在卡片外面,而窗口区域正好裁在卡片边界上, - // 画了也会被切掉,所以边框画在卡片内部。 - border: Border.all(color: _edge, width: 1), - borderRadius: BorderRadius.circular(settings.cardRadius), - ), - padding: contentPadding, - child: Stack( - children: [ - Positioned.fill( - child: DefaultTextStyle( - style: TextStyle( - color: _foreground, - fontSize: 13, - // 这里的 DefaultTextStyle 是替换式的(不带 merge),会把 - // 主题里传下来的 fontFamily 冲掉,必须显式带上全局字体 - fontFamily: 'TsukushiBMaru', - decoration: TextDecoration.none, + // 编辑模式的边框提示。不做抖动动画——那属于"特效"。 + if (editing) + IgnorePointer( + child: Container( + width: width, + height: height, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(settings.cardRadius), + border: Border.all( + color: const Color(0x66FFFFFF), + width: 2, ), - child: child, ), ), - ], - ), - ), - ), - // 编辑模式的边框提示。不做抖动动画——那属于"特效"。 - if (editing) - IgnorePointer( - child: Container( - width: width, - height: height, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(settings.cardRadius), - border: Border.all(color: const Color(0x66FFFFFF), width: 2), ), - ), - ), - ], - ), - ), + ], + ), + ); + }, ); } } diff --git a/lib/ui/market_app.dart b/lib/ui/market_app.dart index 2e0d0de..32d7b07 100644 --- a/lib/ui/market_app.dart +++ b/lib/ui/market_app.dart @@ -50,7 +50,7 @@ class MarketApp extends StatelessWidget { title: 'Vectra 插件市场', theme: FluentThemeData( brightness: light ? Brightness.light : Brightness.dark, - fontFamily: 'TsukushiBMaru', + fontFamily: 'SourceHanSansSC', ), home: _MarketWindow( light: light, diff --git a/lib/ui/panel.dart b/lib/ui/panel.dart index 4eee026..e8c6682 100644 --- a/lib/ui/panel.dart +++ b/lib/ui/panel.dart @@ -17,15 +17,17 @@ import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:fluent_ui/fluent_ui.dart'; -import 'package:flutter/material.dart' show Icons; import 'package:package_info_plus/package_info_plus.dart'; -import 'package:url_launcher/url_launcher.dart'; +import 'package:path/path.dart' as p; import '../core/app_version.dart' show appVersion; import '../core/logger.dart'; import '../core/paths.dart'; import '../core/theme.dart'; +import 'card_view.dart'; import '../core/updater.dart'; +import '../config/config_binding.dart'; +import '../config/config_coordinator.dart'; import '../model/ai_settings.dart'; import '../model/card.dart'; import '../model/settings.dart'; @@ -40,7 +42,8 @@ import 'wallpaper.dart'; /// 全局统一的主色与材质,跟磁贴/侧边栏同一套语言 /// (主题蓝放 _PanelColors.accent 里按深浅色翻转) -const double _kRadius = 14; +/// 面板统一圆角:Win11 设置的设置卡片是 8px,跟着走 +const double _kRadius = 8; /// 面板自身的一套颜色,按深浅色翻转。 /// @@ -72,10 +75,16 @@ class _PanelColors { Color get ink24 => light ? Color(0x3D16181C) : Color(0x3DFFFFFF); - /// 卡片底/边框:深色是白 5%/12%,浅色换成黑 5%/12% - Color get card => light ? Color(0x0D000000) : Color(0x0DFFFFFF); + /// 卡片底/边框:Win11 设置风格的**实色**卡片(浅色纯白 / 深色比底色亮一档) + Color get card => light ? Color(0xFFFFFFFF) : Color(0xFF23292B); Color get cardBorder => - light ? Color(0x1F000000) : Color(0x1FFFFFFF); + light ? Color(0x14000000) : Color(0x1FFFFFFF); + + /// 侧边栏导航项的悬停 / 选中底(Win11 那种浅灰胶囊) + Color get navHover => + light ? Color(0x0A000000) : Color(0x0AFFFFFF); + Color get navSelected => + light ? Color(0x14181C1C) : Color(0x1FFFFFFF); /// 未选中 chip / 分隔线的底(白 8% ↔ 黑 8%) Color get chipBg => @@ -171,13 +180,10 @@ class ControlPanel extends StatefulWidget { class _ControlPanelState extends State { late int _tab = widget.initialTab ?? (widget.focusCardId != null ? 1 : 0); - /// 导航栏是展开还是收成图标条。 - /// - /// 必须自己存着:NavigationView 的收起按钮只通过 onDisplayModeChanged - /// 把新模式**报出来**,它自己不留状态。之前这里给的是写死的 - /// PaneDisplayMode.expanded,按钮按下去内部虽然变了,可下一次重建又被 - /// 这个常量按回展开——表现就是"点了没反应"。 - PaneDisplayMode _paneMode = PaneDisplayMode.expanded; + /// 顶部搜索框:内容与焦点 + final TextEditingController _searchCtrl = TextEditingController(); + final FocusNode _searchFocus = FocusNode(); + String _query = ''; /// 关于页的版本信息;异步加载,未就绪时显示"获取中…" PackageInfo? _pkgInfo; @@ -203,10 +209,158 @@ class _ControlPanelState extends State { _PanelColors get _c => _PanelColors(effectiveBrightness(_s) == Brightness.light); + late final ConfigCoordinator _config; + final Map> _cardBindings = {}; + final Map> _coreBindings = {}; + + ConfigBinding _register(ConfigSpec spec) => _config.register(spec); + + void _registerCoreBindings() { + void add(String key, ConfigScope scope, Object? Function() read, + void Function(Object?) write) { + _coreBindings[key] = _register(ConfigSpec( + key: key, + scope: scope, + type: ConfigValueType.json, + read: read, + write: write, + )); + } + + final s = widget.state.settings; + add('settings.gridCell', ConfigScope.app, () => s.gridCell, + (v) => s.gridCell = (v as num).round()); + add('settings.gridGap', ConfigScope.app, () => s.gridGap, + (v) => s.gridGap = (v as num).round()); + add('settings.snapThreshold', ConfigScope.app, () => s.snapThreshold, + (v) => s.snapThreshold = (v as num).toDouble()); + add('settings.snapEnabled', ConfigScope.app, () => s.snapEnabled, + (v) => s.snapEnabled = v as bool); + add('settings.locked', ConfigScope.app, () => s.locked, + (v) => s.locked = v as bool); + add('settings.animations', ConfigScope.app, () => s.animations, + (v) => s.animations = v as bool); + add('settings.cardRadius', ConfigScope.app, () => s.cardRadius, + (v) => s.cardRadius = (v as num).toDouble()); + add('settings.glassTint', ConfigScope.app, () => s.glassTint, + (v) => s.glassTint = (v as num).toDouble()); + add('settings.glassBlur', ConfigScope.app, () => s.glassBlur, + (v) => s.glassBlur = (v as num).toDouble()); + add('settings.autoColorFromWallpaper', ConfigScope.app, + () => s.autoColorFromWallpaper, + (v) => s.autoColorFromWallpaper = v as bool); + add('settings.autoForegroundFromWallpaper', ConfigScope.app, + () => s.autoForegroundFromWallpaper, + (v) => s.autoForegroundFromWallpaper = v as bool); + add('settings.cardColor', ConfigScope.app, () => s.cardColor, + (v) => s.cardColor = (v as num).toInt()); + add('settings.theme', ConfigScope.app, () => s.theme, + (v) => s.theme = v as String); + add('settings.material', ConfigScope.app, () => s.material, + (v) => s.material = v as String); + add('settings.liveRefreshMs', ConfigScope.app, () => s.liveRefreshMs, + (v) => s.liveRefreshMs = (v as num).round()); + add('settings.autoDownloadUpdate', ConfigScope.app, + () => s.autoDownloadUpdate, + (v) => s.autoDownloadUpdate = v as bool); + add('settings.updateSource', ConfigScope.app, () => s.updateSource, + (v) => s.updateSource = v as String); + + final ai = widget.state.ai; + add('ai.baseUrl', ConfigScope.ai, () => ai.baseUrl, + (v) => ai.baseUrl = v as String); + add('ai.apiKey', ConfigScope.ai, () => ai.apiKey, + (v) => ai.apiKey = v as String); + add('ai.model', ConfigScope.ai, () => ai.model, + (v) => ai.model = v as String); + add('ai.temperature', ConfigScope.ai, () => ai.temperature, + (v) => ai.temperature = (v as num).toDouble()); + add('ai.maxHistory', ConfigScope.ai, () => ai.maxHistory, + (v) => ai.maxHistory = (v as num).round()); + add('ai.systemPrompt', ConfigScope.ai, () => ai.systemPrompt, + (v) => ai.systemPrompt = v as String); + add('ai.sidebarWidth', ConfigScope.ai, () => ai.sidebarWidth, + (v) => ai.sidebarWidth = (v as num).toDouble()); + add('ai.radius', ConfigScope.ai, () => ai.radius, + (v) => ai.radius = (v as num).toDouble()); + add('ai.glass', ConfigScope.ai, () => ai.glass, + (v) => ai.glass = v as bool); + add('ai.tint', ConfigScope.ai, () => ai.tint, + (v) => ai.tint = (v as num).toDouble()); + add('ai.agent', ConfigScope.ai, () => ai.agent, + (v) => ai.agent = v as bool); + add('ai.dock', ConfigScope.ai, () => ai.dock, + (v) => ai.dock = v as bool); + add('ai.hotkeyVk', ConfigScope.ai, () => ai.hotkeyVk, + (v) => ai.hotkeyVk = (v as num).round()); + } + + void _setConfig(String key, Object? value, {bool hotkey = false}) { + final binding = _coreBindings[key]; + if (binding == null) { + throw StateError('Unregistered configuration: $key'); + } + _config.set(binding, value, hotkey: hotkey); + _maybeBumpThemeRevision(); + setState(() {}); + } + + ConfigBinding _cardBinding(WidgetCard card, Map field) { + final key = field['key'] as String; + final id = '${card.id}:$key'; + final existing = _cardBindings[id]; + if (existing != null) return existing as ConfigBinding; + final typeName = field['type']; + final type = switch (typeName) { + 'boolean' => ConfigValueType.boolean, + 'number' => ConfigValueType.number, + 'select' => ConfigValueType.select, + _ => ConfigValueType.text, + }; + final binding = _register(ConfigSpec( + key: 'card:${card.id}.$key', + scope: ConfigScope.card, + type: type, + read: () { + final value = card.settings.containsKey(key) + ? card.settings[key] + : field['default']; + if (value != null) return value as T; + return switch (type) { + ConfigValueType.boolean => false as T, + ConfigValueType.number => ((field['min'] as num?) ?? 0) as T, + _ => '' as T, + }; + }, + write: (value) => card.settings[key] = value, + defaultValue: field['default'] as T?, + min: (field['min'] as num?)?.toDouble(), + max: (field['max'] as num?)?.toDouble(), + step: (field['step'] as num?)?.toDouble(), + options: [ + for (final option in (field['options'] as List? ?? const [])) + if (option is Map && option['value'] != null) + ConfigOption( + value: option['value'] as T, + label: '${option['label'] ?? option['value']}', + ), + ], + )); + _cardBindings[id] = binding as ConfigBinding; + return binding; + } + @override void initState() { + _config = ConfigCoordinator( + store: widget.store, + state: widget.state, + onChanged: widget.onChanged, + onHotkeyChanged: widget.onHotkeyChanged, + ); super.initState(); _lastBrightness = effectiveBrightness(_s); + _registerCoreBindings(); // 先拍一张基线,否则第一次改动会把所有设置项都算成"变了" _settingsSnapshot = widget.state.settings.toJson(); Log.i('panel', '打开设置窗口(页 $_tab)'); @@ -249,18 +403,11 @@ class _ControlPanelState extends State { void _commit() { _logSettingsDiff(); - widget.store.save(widget.state); + _config.commit(); setState(() {}); // 立刻检查生效亮度:手动切换主题时希望面板外壳(背景 / fluent 控件的 // 主题色)跟着变,不必等 260ms 去抖。 _maybeBumpThemeRevision(); - _commitTimer?.cancel(); - _commitTimer = Timer(const Duration(milliseconds: 260), () { - _commitTimer = null; - // 去抖后再兜一次:系统深浅色切换不会走 setState,可能正好在这期间翻转 - _maybeBumpThemeRevision(); - if (mounted) widget.onChanged(); - }); } void _maybeBumpThemeRevision() { @@ -271,23 +418,18 @@ class _ControlPanelState extends State { } } - Timer? _commitTimer; - @override void dispose() { - // 面板关掉时还欠着一次通知,补上——否则最后那次改动要等下次才生效 - if (_commitTimer?.isActive ?? false) { - _commitTimer!.cancel(); - widget.onChanged(); - } + _config.dispose(); + _searchCtrl.dispose(); + _searchFocus.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - // WinUI 3 标准布局:无边框自绘标题栏(最顶)+ NavigationView(左侧导航 - // 窗格 + 右侧内容)。NavigationView 的 body 按选中项构建(keyed 切换), - // 不会像 TabView/IndexedStack 那样同时建多页,安全的。 + // Win11 设置布局:无边框自绘标题栏(最顶,中间有"查找设置")+ + // 左侧栏 + 右侧内容。内容按选中项构建,不会同时建多页。 final body = Column( children: [ // 无边框窗口的自绘标题栏;内嵌模式没有独立窗口,不需要 @@ -296,13 +438,25 @@ class _ControlPanelState extends State { ], ); + // 搜索结果浮层挂在 body 上层的 Stack 里,从标题栏正下方弹出。 + // 放在 Column 外面(Positioned),弹出时不把内容往下顶。 + final layered = Stack( + fit: StackFit.expand, + children: [ + body, + ...[ + if (!widget.embedded) _searchResultsPanel() ?? const SizedBox.shrink(), + ], + ], + ); + // 独立窗口:铺满客户区,底色由 panel_app 负责。 // 四周叠一圈缩放手柄——这窗口没有系统缩放边框(见 panel_window.cpp)。 if (!widget.embedded) { // fit 用 expand:Stack 的子节点全是 Positioned 时,它自己会塌缩到约束 // 允许的最小尺寸,手柄就跟着缩没了(surface.dart 里踩过同样的坑) return Stack(fit: StackFit.expand, children: [ - Positioned.fill(child: body), + layered, ...resizeHandles(NativeWindow.panel), // 描边。窗口是无边框的,浅色主题下面板底色和浅色桌面/浅色应用背景 // 挨在一起时几乎分不出边界,一圈淡黑色才能把窗口"框"出来。 @@ -353,77 +507,344 @@ class _ControlPanelState extends State { ); } - // ---------------- 左侧导航(NavigationView) ---------------- + // ---------------- Win11 设置风格的导航壳 ---------------- + + /// 导航元数据(图标 + 标题)。**顺序即索引**:AI 固定在 3、其他 4、 + /// 关于 5——托盘的 openPanel(tab: 3)、AI 侧边栏齿轮的跳转都按这个 + /// 下标写死,别动。 + static const List<(int, IconData, String)> _navItems = [ + (0, FluentIcons.grid_view_medium, '组件库'), + (1, FluentIcons.grid_view_medium, '已放置'), + (2, FluentIcons.color, '外观'), + (3, FluentIcons.robot, 'AI'), + (4, FluentIcons.settings, '其他'), + ]; Widget _navigation() { - return NavigationView( - // 收起按钮只负责把新模式报出来,记不记得住得靠调用方。 - // 这个回调挂在 NavigationView 上,不是 NavigationPane 上。 - onDisplayModeChanged: (m) { - if (m != _paneMode) setState(() => _paneMode = m); - }, - pane: NavigationPane( - selected: _tab, - onChanged: (i) => setState(() => _tab = i), - displayMode: _paneMode, - size: const NavigationPaneSize(openWidth: 220), - // 品牌行不放了:标题栏已有「Vectra 设置」,导航栏顶部再放一遍重复 - items: [ - PaneItem( - icon: Icon(Icons.widgets_outlined, size: 18), - title: Text('组件库'), - body: _pageFrame('组件库', _library()), + // Win11 设置布局:左侧固定宽度侧栏(品牌卡 + 导航列表 + 底部固定项), + // 中间一条发丝分隔线,右侧内容区只建当前选中页。 + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _sidebar(), + Container(width: 1, color: _c.chipBg), + Expanded(child: _pageSwitcher()), + ], + ); + } + + /// 当前页内容。按需懒构建——以前每次 setState 会把 5 个页面(含组件库 + /// 那些实时跑的插件预览)全构建一遍,只挂一页也是白费力气。 + Widget _currentPage() => switch (_tab) { + 0 => _pageFrame('组件库', _library()), + 1 => _pageFrame('已放置', _placed()), + 2 => _pageFrame('外观', _appearance()), + 3 => _pageFrame('AI', _aiSettings()), + 4 => _pageFrame('其他', _other()), + _ => _pageFrame('关于', _about()), + }; + + /// 页面切换动画:淡入 + 轻微上移(Win11 设置换页时的那种感觉)。 + /// + /// 进和出用**不对称**的节奏:新页 200ms 缓动入场,旧页 100ms 快速 + /// 淡出(easeInCubic 起手就猛退)。AnimatedSwitcher 默认新旧交叉淡变 + /// 且时长相同,旧页文字会几乎全程挂在新页上——"切换时字体留存过久" + /// 就是对称交叉淡变留下的残影,缩短 reverseDuration 并加速退场解决。 + Widget _pageSwitcher() { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + reverseDuration: const Duration(milliseconds: 100), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) { + // 透明度线性(时长由 duration/reverseDuration 控制);缓动曲线只给 + // 位移。旧页退场时如果透明度也走 easeOutCubic,反向播放会在接近 + // 完全不透明处滞留到最后一刻——"字体留存"就是这么来的。 + final slide = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ); + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.012), + end: Offset.zero, + ).animate(slide), + child: child, ), - PaneItem( - icon: Icon(Icons.grid_view_outlined, size: 18), - title: Text('已放置 ${widget.state.cards.length}'), - body: _pageFrame('已放置', _placed()), + ); + }, + child: KeyedSubtree( + key: ValueKey('page:$_tab'), + child: _currentPage(), + ), + ); + } + + Widget _sidebar() { + return SizedBox( + width: 256, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _brandCard(), + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + children: [ + for (final (index, icon, title) in _navItems) + _navItem(index, icon, title, + // 已放置的卡片数量跟在标题后面,和旧导航栏的信息一致 + trailing: index == 1 && widget.state.cards.isNotEmpty + ? '${widget.state.cards.length}' + : null), + ], + ), ), - PaneItem( - icon: Icon(Icons.palette_outlined, size: 18), - title: Text('外观'), - body: _pageFrame('外观', _appearance()), + Padding( + padding: const EdgeInsets.fromLTRB(10, 4, 10, 10), + child: _navItem(5, FluentIcons.info, '关于'), ), - PaneItem( - icon: Icon(Icons.smart_toy_outlined, size: 18), - title: Text('AI'), - body: _pageFrame('AI', _aiSettings()), + ], + ), + ); + } + + /// 侧栏顶部的品牌卡(对应 Win11 设置里的账户卡:头像 + 名称 + 说明) + Widget _brandCard() { + return Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 8), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.asset('assets/logo.png', + width: 38, height: 38, filterQuality: FilterQuality.medium), ), - // 放在 AI 之后:AI 仍是索引 3,托盘的 openPanel(tab: 3) 不受影响 - PaneItem( - icon: Icon(Icons.tune_outlined, size: 18), - title: Text('其他'), - body: _pageFrame('其他', _other()), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Vectra · 桌面组件', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: _c.ink)), + const SizedBox(height: 2), + Text('v$appVersion · 桌面磁贴', + style: TextStyle(fontSize: 11, color: _c.ink54)), + ], + ), ), ], - // 底部固定项:关于页固定在标签栏下方,不随 items 区滚动 - footerItems: [ - if (widget.embedded) - PaneItemAction( - icon: Icon(Icons.close, size: 18), - title: Text('关闭'), - onTap: widget.onClose, + ), + ); + } + + /// 导航项:Win11 那种浅灰胶囊选中态(选中图标用主色,悬停浅浅一层), + /// 左侧一条主色小指示条随选中伸缩。全部动画用 easeOutCubic 缓动, + /// 起步快、收尾减速,跟 Win11 系统应用同一个节奏。 + Widget _navItem(int index, IconData icon, String title, {String? trailing}) { + final selected = _tab == index; + return Padding( + padding: const EdgeInsets.only(bottom: 2), + child: HoverButton( + onPressed: () => setState(() => _tab = index), + builder: (context, states) { + final Color bg; + if (selected) { + bg = _c.navSelected; + } else if (states.contains(WidgetState.hovered) || + states.contains(WidgetState.pressed)) { + bg = _c.navHover; + } else { + bg = Colors.transparent; + } + return AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(6), + ), + child: Row( + children: [ + // Win11 的选中指示条:3px 宽的圆头小竖条,选中时长出来 + AnimatedContainer( + duration: const Duration(milliseconds: 160), + curve: Curves.easeOutCubic, + width: 3, + height: selected ? 16 : 0, + margin: EdgeInsets.only(right: selected ? 9 : 0), + decoration: BoxDecoration( + color: _c.accent, + borderRadius: BorderRadius.circular(2), + ), + ), + Icon(icon, + size: 18, color: selected ? _c.accent : _c.ink60), + const SizedBox(width: 12), + Expanded( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + style: TextStyle( + fontSize: 13, + fontWeight: + selected ? FontWeight.w600 : FontWeight.w400, + color: selected ? _c.ink : _c.ink70), + child: Text(title), + ), + ), + if (trailing != null) + Text(trailing, + style: TextStyle(fontSize: 11, color: _c.ink38)), + ], + ), + ); + }, + ), + ); + } + + // ---------------- 设置搜索 ---------------- + + /// 搜索索引:关键词 -> 页面。纯静态表,加新分组时顺手补一行就行。 + /// 做成"关键词集合"而不是精确标题:中文没有前缀匹配的必要, + /// 搜"备份"、"启动"这类词能直接命中分组才是想要的体验。 + static const List<(String, int, String)> _searchIndex = [ + ('组件 插件 添加 组件库 市场 预览 第三方', 0, '组件库'), + ('已放置 卡片 尺寸 移除 删除 布局', 1, '已放置'), + ('外观 网格 间距 吸附 对齐 锁定 动画 材质 毛玻璃 云母 圆角 透明度 ' + '底色 颜色 取色 莫奈 主题 深色 浅色 壁纸 刷新 模糊', 2, '外观'), + ('AI 人工智能 接口 base url api key 模型 温度 历史 提示词 ' + '快捷键 热键 投放点 agent 助手', 3, 'AI'), + ('更新 升级 版本 检查更新 下载 更新源 自动下载 自启 开机启动 启动 ' + '日志 备份 导出 导入 恢复', 4, '其他'), + ('关于 版本 作者', 5, '关于'), + ]; + + List<(String, int)> get _searchResults { + final q = _query.trim().toLowerCase(); + if (q.isEmpty) return const []; + return [ + for (final (keywords, tab, label) in _searchIndex) + if (keywords.toLowerCase().contains(q) || + label.toLowerCase().contains(q)) + (label, tab), + ]; + } + + /// 标题栏中间的搜索框("查找设置"那个位置的的东西) + Widget _searchBox() { + return SizedBox( + width: 300, + height: 30, + child: TextBox( + controller: _searchCtrl, + focusNode: _searchFocus, + placeholder: '查找设置', + prefix: Padding( + padding: const EdgeInsets.only(left: 8), + child: Icon(FluentIcons.search, size: 14, color: _c.ink38), + ), + style: const TextStyle(fontSize: 12), + onChanged: (v) => setState(() => _query = v), + ), + ); + } + + /// 搜索结果浮层:标题栏正下方拉出一个卡片,点结果跳页。 + /// 出现时是淡入 + 轻微上提的非线性动画,不是"啪"地闪现。 + Widget? _searchResultsPanel() { + final results = _searchResults; + if (results.isEmpty || !_searchFocus.hasFocus) return null; + return Positioned( + top: 48, + left: 0, + right: 0, + child: Center( + child: TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: const Duration(milliseconds: 160), + curve: Curves.easeOutCubic, + builder: (context, t, child) => Opacity( + opacity: t, + child: Transform.translate( + offset: Offset(0, -6 * (1 - t)), + child: child, ), - PaneItem( - icon: Icon(Icons.info_outline, size: 18), - title: Text('关于'), - body: _pageFrame('关于', _about()), ), - ], + child: Container( + width: 380, + decoration: BoxDecoration( + color: _c.card, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _c.cardBorder), + boxShadow: [ + BoxShadow( + color: Color(0x33000000), + blurRadius: 16, + offset: Offset(0, 6), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final (label, tab) in results) + _searchResultRow(label, tab), + ], + ), + ), + ), ), ); } - /// 内容页的标准骨架:大标题 + 可滚动内容 + Widget _searchResultRow(String label, int tab) { + return HoverButton( + onPressed: () { + setState(() { + _tab = tab; + _query = ''; + _searchCtrl.clear(); + _searchFocus.unfocus(); + }); + }, + builder: (context, states) => Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), + decoration: BoxDecoration( + color: states.contains(WidgetState.hovered) + ? _c.navHover + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Row(children: [ + Icon(FluentIcons.search, size: 13, color: _c.ink38), + const SizedBox(width: 10), + Expanded( + child: Text(label, + style: TextStyle(fontSize: 12.5, color: _c.ink70))), + Icon(FluentIcons.chevron_right, size: 12, color: _c.ink24), + ]), + ), + ); + } + + /// 内容页的标准骨架:大标题 + 可滚动内容(Win11 设置那种 28px 页题) Widget _pageFrame(String title, Widget content) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.fromLTRB(28, 22, 28, 10), + padding: const EdgeInsets.fromLTRB(32, 20, 32, 10), child: Text(title, style: TextStyle( - fontSize: 26, + fontSize: 28, fontWeight: FontWeight.w600, color: _c.ink)), ), @@ -440,9 +861,11 @@ class _ControlPanelState extends State { decoration: BoxDecoration( border: Border(bottom: BorderSide(color: _c.chipBg)), ), - child: Row(children: [ - Expanded( - // 整条标题栏(按钮区域除外)都能拖窗口:按下即让 native 接管鼠标 + child: Stack(children: [ + // 左侧品牌 + 可拖区域。搜索框是 Stack 里的独立兄弟节点,压在 + // 拖动区上方——TextBox 命中时事件到不了下面的拖动 Listener, + // 搜索框里点按就不会把窗口拖走。 + Positioned.fill( child: Listener( behavior: HitTestBehavior.opaque, onPointerDown: _onTitleDrag, @@ -460,26 +883,38 @@ class _ControlPanelState extends State { ]), ), ), - WindowButton( - icon: Icons.minimize_rounded, - tooltip: '最小化', - light: _c.light, - onTap: () => NativeWindow.panel.minimize(), - ), - WindowButton( - icon: Icons.crop_square_rounded, - tooltip: '最大化 / 还原', - light: _c.light, - onTap: () => NativeWindow.panel.toggleMaximize(), + // 标题栏正中的搜索框(对齐 Win11 设置的"查找设置"位置) + Align( + alignment: Alignment.center, + child: _searchBox(), ), - WindowButton( - icon: Icons.close_rounded, - tooltip: '关闭', - light: _c.light, - danger: true, - onTap: widget.onClose, + // 右上角窗口按钮 + Positioned( + right: 4, + top: 0, + bottom: 0, + child: Row(children: [ + WindowButton( + icon: FluentIcons.chrome_minimize, + tooltip: '最小化', + light: _c.light, + onTap: () => NativeWindow.panel.minimize(), + ), + WindowButton( + icon: FluentIcons.chrome_restore, + tooltip: '最大化 / 还原', + light: _c.light, + onTap: () => NativeWindow.panel.toggleMaximize(), + ), + WindowButton( + icon: FluentIcons.chrome_close, + tooltip: '关闭', + light: _c.light, + danger: true, + onTap: widget.onClose, + ), + ]), ), - const SizedBox(width: 4), ]), ); } @@ -496,7 +931,7 @@ class _ControlPanelState extends State { children: [ _group( title: '组件', - icon: Icons.widgets_outlined, + icon: FluentIcons.grid_view_medium, children: [ if (plugins.isEmpty) Padding( @@ -506,21 +941,32 @@ class _ControlPanelState extends State { style: TextStyle(color: _c.ink30, fontSize: 12))), ) else - GridView.count( - crossAxisCount: 2, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - crossAxisSpacing: 12, - mainAxisSpacing: 12, - childAspectRatio: 1.25, - children: [for (final p in plugins) _pluginCard(p)], + // 列数随内容宽度走:宽了 3 列、窄了 2 列、再窄 1 列, + // 不写死(写死 2 列时窄窗口会把卡片挤成细条) + LayoutBuilder( + builder: (context, box) { + final cols = box.maxWidth > 920 + ? 3 + : box.maxWidth > 520 + ? 2 + : 1; + return GridView.count( + crossAxisCount: cols, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisSpacing: 14, + mainAxisSpacing: 14, + childAspectRatio: 1.22, + children: [for (final p in plugins) _pluginCard(p)], + ); + }, ), ], ), if (widget.registry.errors.isNotEmpty) _group( title: '加载失败的插件', - icon: Icons.error_outline, + icon: FluentIcons.error, children: [ for (final e in widget.registry.errors.entries) Padding( @@ -531,24 +977,6 @@ class _ControlPanelState extends State { ), ], ), - _group( - title: '获取更多组件', - icon: Icons.storefront_outlined, - children: [ - Row(children: [ - Expanded( - child: Text( - '在插件市场浏览、安装社区做的组件。', - style: TextStyle(color: _c.ink60, fontSize: 12), - ), - ), - FilledButton( - onPressed: () => NativeWindow.market.show(), - child: const Text('打开插件市场'), - ), - ]), - ], - ), Padding( padding: const EdgeInsets.only(top: 2), child: Text( @@ -569,23 +997,20 @@ class _ControlPanelState extends State { final blocked = singleBlocked || screenFull; final loaded = widget.registry[p.id]; return Container( - padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: _c.card, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(_kRadius), border: Border.all(color: _c.cardBorder), ), + clipBehavior: Clip.antiAlias, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 真实实时预览:插件真的在跑 + // 真实实时预览:插件真的在跑。占据卡片上方、高度随卡片宽度走 Expanded( child: Container( width: double.infinity, - decoration: BoxDecoration( - color: _c.previewBg, - borderRadius: BorderRadius.circular(10), - ), + decoration: BoxDecoration(color: _c.previewBg), clipBehavior: Clip.antiAlias, child: FittedBox( fit: BoxFit.contain, @@ -599,76 +1024,101 @@ class _ControlPanelState extends State { ), ), ), - const SizedBox(height: 10), - Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(p.icon, style: TextStyle(fontSize: 20)), - const SizedBox(width: 8), - Expanded( + // 信息区固定高度:以前用 Expanded 均分,预览把信息挤到只剩一行, + // 按钮都贴边了;改成"预览吃掉剩余高度 + 信息区定高",任何卡片 + // 宽度下信息排布都一致 + SizedBox( + height: 112, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Flexible( - child: Text(p.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: _c.ink)), - ), - const SizedBox(width: 6), - Text('v${p.version}', - style: TextStyle(fontSize: 10, color: _c.ink30)), - if (p.source == 'user') ...[ - const SizedBox(width: 6), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ Container( - padding: const EdgeInsets.symmetric( - horizontal: 5, vertical: 1), + width: 32, + height: 32, decoration: BoxDecoration( - color: _c.badgeBg, - borderRadius: BorderRadius.circular(4)), - child: Text('第三方', - style: TextStyle( - fontSize: 9, color: _c.accentSoft)), + color: _c.iconTile, + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: Text(p.icon, + style: const TextStyle(fontSize: 17)), + ), + const SizedBox(width: 9), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Flexible( + child: Text(p.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: _c.ink)), + ), + const SizedBox(width: 5), + Text('v${p.version}', + style: TextStyle( + fontSize: 10, color: _c.ink30)), + if (p.source == 'user') ...[ + const SizedBox(width: 5), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: _c.badgeBg, + borderRadius: BorderRadius.circular(4)), + child: Text('第三方', + style: TextStyle( + fontSize: 9, color: _c.accentSoft)), + ), + ], + ]), + if (p.description.isNotEmpty) ...[ + const SizedBox(height: 2), + Text(p.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10.5, color: _c.ink38)), + ], + ], + ), ), ], + ), + const Spacer(), + Row(children: [ + if (placed > 0) + Text('已放置 $placed', + style: TextStyle(fontSize: 11, color: _c.ink30)), + const Spacer(), + FilledButton( + onPressed: blocked + ? null + : () { + widget.onAdd(p); + // 导航项标题上有已放置数量,添加后要刷新 + if (mounted) setState(() {}); + }, + child: Text( + singleBlocked + ? '仅一个' + : (screenFull ? '每屏一个' : '添加'), + style: const TextStyle(fontSize: 12)), + ), ]), - if (p.description.isNotEmpty) - Text(p.description, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 10.5, color: _c.ink38, height: 1.3)), ], ), ), - ]), - const SizedBox(height: 8), - Row(children: [ - if (placed > 0) - Text('已放置 $placed', - style: TextStyle(fontSize: 11, color: _c.ink30)), - const Spacer(), - FilledButton( - style: ButtonStyle( - backgroundColor: - WidgetStateProperty.all(_c.cardBorder), - padding: WidgetStateProperty.all( - const EdgeInsets.symmetric(horizontal: 12, vertical: 5)), - ), - onPressed: blocked - ? null - : () { - widget.onAdd(p); - // 导航项标题上有已放置数量,添加后要刷新 - if (mounted) setState(() {}); - }, - child: Text( - singleBlocked ? '仅一个' : (screenFull ? '每屏一个' : '添加'), - style: TextStyle(fontSize: 12)), - ), - ]), + ), ], ), ); @@ -728,7 +1178,7 @@ class _ControlPanelState extends State { Tooltip( message: '移除', child: IconButton( - icon: Icon(Icons.delete_outline, + icon: Icon(FluentIcons.delete, size: 18, color: _c.ink38), onPressed: () { widget.onRemove(card); @@ -738,6 +1188,8 @@ class _ControlPanelState extends State { ), ], ), + const SizedBox(height: 8), + _bgPicker(card), if (plugin != null) ...[ const SizedBox(height: 8), _sizePicker(card, plugin), @@ -766,6 +1218,7 @@ class _ControlPanelState extends State { }, child: AnimatedContainer( duration: const Duration(milliseconds: 160), + curve: Curves.easeOutCubic, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: card.size == s @@ -784,6 +1237,105 @@ class _ControlPanelState extends State { } /// 设置项控件类型与 Electron 版一致:boolean / select / number / 其余按文本 + /// 每卡自定义背景图(Fluent 形态):缩略图 + 状态行 + 图标按钮。 + /// 选图复制到 userdata/bg/,卡片的 bgImage 设置只存文件名; + /// 可读性由 card_view 按图片实测亮度自动翻转文字色,这里只把结果说出来。 + Widget _bgPicker(WidgetCard card) { + final current = card.settings['bgImage'] as String?; + final file = current == null || current.isEmpty + ? null + : File(p.join(widget.store.dir, 'bg', current)); + final fileReady = file != null && file.existsSync(); + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Container( + width: 44, + height: 44, + color: _c.chipBg, + child: fileReady + ? Image.file(file!, fit: BoxFit.cover, gaplessPlayback: true) + : Center( + child: Icon(FluentIcons.photo2, + size: 16, color: _c.ink38)), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('背景图片', + style: TextStyle(fontSize: 13, color: _c.ink)), + const SizedBox(height: 2), + ValueListenableBuilder( + valueListenable: CardView.bgRevision, + builder: (context, _, __) { + String status; + if (!fileReady) { + status = '未设置 · 使用主题材质(云母/毛玻璃)'; + } else { + final l = CardView.bgLuminance(file!.path); + final adapt = l == null + ? '' + : (l > 0.5 ? ' · 浅色图,文字已自动切黑' : ' · 深色图,文字已自动切白'); + status = '$current$adapt'; + } + return Text(status, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 11, color: _c.ink38)); + }, + ), + ], + ), + ), + Button( + onPressed: () async { + final result = await FilePicker.pickFiles(type: FileType.image); + final path = result?.files.single.path; + if (path == null) return; + final dir = Directory(p.join(widget.store.dir, 'bg')); + await dir.create(recursive: true); + final ext = path.split('.').last.toLowerCase(); + final name = '${card.id}.$ext'; + if (current != null && current != name) { + final old = File(p.join(dir.path, current)); + if (old.existsSync()) old.deleteSync(); + } + await File(path).copy(p.join(dir.path, name)); + card.settings['bgImage'] = name; + _commit(); + if (mounted) setState(() {}); + }, + child: Row(mainAxisSize: MainAxisSize.min, children: [ + const Icon(FluentIcons.photo2, size: 14), + const SizedBox(width: 6), + Text(current == null ? '选择图片' : '更换'), + ]), + ), + if (current != null) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Button( + onPressed: () { + final old = File(p.join(widget.store.dir, 'bg', current)); + if (old.existsSync()) old.deleteSync(); + card.settings.remove('bgImage'); + _commit(); + if (mounted) setState(() {}); + }, + child: const Text('清除', + style: TextStyle(color: Color(0xFFD13438))), + ), + ), + ], + ); + } + Widget _settingField(WidgetCard card, Map f) { final key = f['key'] as String; final label = f['label'] as String? ?? key; @@ -799,8 +1351,8 @@ class _ControlPanelState extends State { control = ToggleSwitch( checked: current == true, onChanged: (v) { - card.settings[key] = v; - _commit(); + _config.set(_cardBinding(card, f), v); + setState(() {}); }, ); case 'select': @@ -820,8 +1372,8 @@ class _ControlPanelState extends State { ], onChanged: (v) { if (v == null) return; - card.settings[key] = v; - _commit(); + _config.set(_cardBinding(card, f), v); + setState(() {}); }, ); case 'number': @@ -839,8 +1391,11 @@ class _ControlPanelState extends State { max: max, divisions: ((max - min) / step).round().clamp(1, 1000), onChanged: (nv) { - card.settings[key] = snapNumber(nv, min, step); - _commit(); + _config.set( + _cardBinding(card, f), + snapNumber(nv, min, step), + ); + setState(() {}); }, ), ), @@ -857,8 +1412,8 @@ class _ControlPanelState extends State { initial: '${current ?? ''}', placeholder: f['placeholder'] as String?, onSubmitted: (v) { - card.settings[key] = v; - _commit(); + _config.set(_cardBinding(card, f), v); + setState(() {}); }, ), ); @@ -894,51 +1449,42 @@ class _ControlPanelState extends State { children: [ _group( title: '布局与吸附', - icon: Icons.grid_view_outlined, + icon: FluentIcons.grid_view_medium, children: [ _slider('网格单元大小', _s.gridCell.toDouble(), 72, 180, 4, (v) { - _s.gridCell = v.round(); - _commit(); + _setConfig('settings.gridCell', v.round()); }, suffix: 'px'), _slider('网格间距', _s.gridGap.toDouble(), 0, 32, 2, (v) { - _s.gridGap = v.round(); - _commit(); + _setConfig('settings.gridGap', v.round()); }, suffix: 'px'), _slider('吸附阈值', _s.snapThreshold, 2, 40, 1, (v) { - _s.snapThreshold = v; - _commit(); + _setConfig('settings.snapThreshold', v); }, suffix: 'px'), _switch('磁吸对齐', _s.snapEnabled, (v) { - _s.snapEnabled = v; - _commit(); + _setConfig('settings.snapEnabled', v); }), _switch('锁定布局(禁止拖动与改尺寸)', _s.locked, (v) { - _s.locked = v; - _commit(); + _setConfig('settings.locked', v); }), _switch('动画效果(拖拽缓动与插件内容切换)', _s.animations, (v) { - _s.animations = v; - _commit(); + _setConfig('settings.animations', v); }), ], ), _group( title: '卡片材质', - icon: Icons.blur_on_outlined, + icon: FluentIcons.blur, children: [ _materialPicker(), _slider('圆角', _s.cardRadius, 0, 40, 1, (v) { - _s.cardRadius = v; - _commit(); + _setConfig('settings.cardRadius', v); }, suffix: 'px'), if (_s.material != 'opaque') ...[ _slider('透明度(染色越少越透)', 1 - _s.glassTint, 0, 1, 0.05, (v) { - _s.glassTint = 1 - v; - _commit(); + _setConfig('settings.glassTint', 1 - v); }, percent: true), _slider('模糊强度', _s.glassBlur, 0, 40, 1, (v) { - _s.glassBlur = v; - _commit(); + _setConfig('settings.glassBlur', v); }, suffix: 'px'), // 云母是静态材质,刷新率对它没有意义,别摆出来误导人 if (_s.material != 'mica') @@ -989,11 +1535,10 @@ class _ControlPanelState extends State { ), _group( title: '卡片底色', - icon: Icons.color_lens_outlined, + icon: FluentIcons.color, children: [ _switch('从壁纸取色(莫奈取色)', _s.autoColorFromWallpaper, (v) { - _s.autoColorFromWallpaper = v; - _commit(); + _setConfig('settings.autoColorFromWallpaper', v); }), Text( '开着的时候下面选的颜色不生效,改成实时从当前壁纸算一个代表色——' @@ -1003,8 +1548,7 @@ class _ControlPanelState extends State { ), const SizedBox(height: 4), _switch('文字颜色也用取色(莫奈取色)', _s.autoForegroundFromWallpaper, (v) { - _s.autoForegroundFromWallpaper = v; - _commit(); + _setConfig('settings.autoForegroundFromWallpaper', v); }), Text( '默认文字颜色只有"深底白字/浅底黑字"两档;开着这个之后文字颜色' @@ -1018,6 +1562,7 @@ class _ControlPanelState extends State { child: AnimatedOpacity( opacity: _s.autoColorFromWallpaper ? 0.35 : 1.0, duration: const Duration(milliseconds: 150), + curve: Curves.easeOutCubic, child: Wrap( spacing: 10, runSpacing: 10, @@ -1028,8 +1573,7 @@ class _ControlPanelState extends State { ]) GestureDetector( onTap: () { - _s.cardColor = c; - _commit(); + _setConfig('settings.cardColor', c); }, child: Container( width: 34, @@ -1045,7 +1589,7 @@ class _ControlPanelState extends State { ), ), child: _s.cardColor == c - ? Icon(Icons.check, + ? Icon(FluentIcons.check_mark, size: 16, color: Color(c).computeLuminance() > 0.5 ? Color(0x8A000000) @@ -1067,7 +1611,7 @@ class _ControlPanelState extends State { ), _group( title: '主题', - icon: Icons.light_mode_outlined, + icon: FluentIcons.brightness, children: [ _themePicker(), Text( @@ -1103,11 +1647,11 @@ class _ControlPanelState extends State { padding: const EdgeInsets.only(right: 8), child: GestureDetector( onTap: () { - _s.theme = m.$1; - _commit(); + _setConfig('settings.theme', m.$1); }, child: AnimatedContainer( duration: const Duration(milliseconds: 160), + curve: Curves.easeOutCubic, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), decoration: BoxDecoration( color: _s.theme == m.$1 @@ -1149,11 +1693,11 @@ class _ControlPanelState extends State { padding: const EdgeInsets.only(right: 8), child: GestureDetector( onTap: () { - _s.material = m.$1; - _commit(); + _setConfig('settings.material', m.$1); }, child: AnimatedContainer( duration: const Duration(milliseconds: 160), + curve: Curves.easeOutCubic, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), decoration: BoxDecoration( @@ -1208,8 +1752,7 @@ class _ControlPanelState extends State { for (final o in options) GestureDetector( onTap: () { - _s.liveRefreshMs = o.$1; - _commit(); + _setConfig('settings.liveRefreshMs', o.$1); }, child: Container( padding: @@ -1244,40 +1787,35 @@ class _ControlPanelState extends State { children: [ _group( title: '接口', - icon: Icons.link_outlined, + icon: FluentIcons.link, children: [ _aiField('Base URL', ai.baseUrl, placeholder: 'https://api.openai.com/v1', desc: '按 OpenAI 兼容格式请求 {BaseURL}/chat/completions。' 'DeepSeek、Kimi、本地 Ollama、One API 都可以填。', onSubmit: (v) { - ai.baseUrl = v.trim(); - _commit(); + _setConfig('ai.baseUrl', v.trim()); }), _aiField('API Key', ai.apiKey, obscure: true, desc: '明文存在 state.json 里,请勿把该文件分享出去。', onSubmit: (v) { - ai.apiKey = v.trim(); - _commit(); + _setConfig('ai.apiKey', v.trim()); }), _aiField('模型', ai.model, placeholder: 'gpt-4o-mini', onSubmit: (v) { - ai.model = v.trim(); - _commit(); + _setConfig('ai.model', v.trim()); }), _slider('温度(越高越发散)', ai.temperature, 0, 2, 0.1, (v) { - ai.temperature = v; - _commit(); + _setConfig('ai.temperature', v); }, decimals: 1), _slider('携带历史条数', ai.maxHistory.toDouble(), 2, 60, 2, (v) { - ai.maxHistory = v.round(); - _commit(); + _setConfig('ai.maxHistory', v.round()); }), ], ), _group( title: '对话', - icon: Icons.chat_outlined, + icon: FluentIcons.chat, children: [ Text('系统提示词', style: TextStyle(fontSize: 12, color: _c.ink70)), @@ -1287,8 +1825,7 @@ class _ControlPanelState extends State { maxLines: 5, minLines: 3, onChanged: (v) { - ai.systemPrompt = v; - _commit(); + _setConfig('ai.systemPrompt', v); }, ), const SizedBox(height: 4), @@ -1298,24 +1835,20 @@ class _ControlPanelState extends State { ), _group( title: '外观', - icon: Icons.palette_outlined, + icon: FluentIcons.color, children: [ _slider('侧边栏宽度', ai.sidebarWidth, 280, 640, 10, (v) { - ai.sidebarWidth = v; - _commit(); + _setConfig('ai.sidebarWidth', v); }, suffix: 'px'), _slider('侧边栏圆角', ai.radius, 0, 48, 2, (v) { - ai.radius = v; - _commit(); + _setConfig('ai.radius', v); }, suffix: 'px'), _switch('侧边栏用毛玻璃(与磁贴同一材质)', ai.glass, (v) { - ai.glass = v; - _commit(); + _setConfig('ai.glass', v); }), if (ai.glass) _slider('侧边栏不透明度', ai.tint, 0, 1, 0.05, (v) { - ai.tint = v; - _commit(); + _setConfig('ai.tint', v); }, percent: true), const SizedBox(height: 4), Text('侧边栏的材质与磁贴共用同一张预模糊图,但透明度和圆角单独调。' @@ -1325,19 +1858,17 @@ class _ControlPanelState extends State { ), _group( title: '行为', - icon: Icons.tune_outlined, + icon: FluentIcons.settings, children: [ _switch('Agent 能力(让 AI 操作电脑与读文件)', ai.agent, (v) { - ai.agent = v; - _commit(); + _setConfig('ai.agent', v); }), Text('开启后 AI 可以读文件、查系统信息、调音量、开设置页等。' '执行脚本、删文件、关机重启这类会先弹卡片让你确认。', style: TextStyle(fontSize: 10, color: _c.ink24)), const SizedBox(height: 8), _switch('右下角投放点(收起后缩成小方块)', ai.dock, (v) { - ai.dock = v; - _commit(); + _setConfig('ai.dock', v); }), Text('把文件拖到屏幕右下角那个小方块上,侧边栏就会展开并把文件挂成附件。' '它是常驻置顶的——磁贴常驻在最底层,右下角一被别的窗口盖住就够不到,' @@ -1347,7 +1878,7 @@ class _ControlPanelState extends State { ), _group( title: '快捷键', - icon: Icons.keyboard_outlined, + icon: FluentIcons.keyboard_classic, children: [ _hotkeyRow(ai), const SizedBox(height: 8), @@ -1356,7 +1887,7 @@ class _ControlPanelState extends State { builder: (context, msg, _) { final bad = msg.startsWith('注册失败'); return Row(children: [ - Icon(bad ? Icons.error_outline : Icons.check_circle_outline, + Icon(bad ? FluentIcons.error : FluentIcons.completed, size: 13, color: bad ? Color(0xFFFF9E7D) : Color(0xFF7CE38B)), @@ -1421,7 +1952,9 @@ class _ControlPanelState extends State { _commit(); widget.onHotkeyChanged?.call(); }, - child: Container( + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: on ? _c.accentBg : _c.chipBg, @@ -1457,8 +1990,7 @@ class _ControlPanelState extends State { ], onChanged: (v) { if (v == null) return; - ai.hotkeyVk = v; - _commit(); + _setConfig('ai.hotkeyVk', v, hotkey: true); widget.onHotkeyChanged?.call(); }, ), @@ -1497,7 +2029,7 @@ class _ControlPanelState extends State { width: inPreset ? 1 : 2, ), ), - child: Icon(Icons.colorize, size: 16, color: glyphColor), + child: Icon(FluentIcons.color, size: 16, color: glyphColor), ), ), ); @@ -1556,7 +2088,7 @@ class _ControlPanelState extends State { child: Text('应用'), onPressed: () { final finalColor = _pickedColor ?? draft; - _s.cardColor = finalColor.toARGB32(); + _setConfig('settings.cardColor', finalColor.toARGB32()); Navigator.pop(context, true); }, ), @@ -1616,7 +2148,7 @@ class _ControlPanelState extends State { style: TextStyle(fontSize: 12, color: _c.ink70)); case AppUpdatePhase.upToDate: return Row(children: [ - Icon(Icons.check_circle_outline, size: 14, color: _c.ink60), + Icon(FluentIcons.completed, size: 14, color: _c.ink60), const SizedBox(width: 6), Text('已是最新版本', style: TextStyle(fontSize: 12, color: _c.ink70)), const SizedBox(width: 12), @@ -1652,7 +2184,7 @@ class _ControlPanelState extends State { ); case AppUpdatePhase.ready: return Row(children: [ - Icon(Icons.download_done_outlined, size: 14, color: _c.ink60), + Icon(FluentIcons.completed, size: 14, color: _c.ink60), const SizedBox(width: 6), Text('新版本 ${snap.update!.version} 已就绪', style: TextStyle(fontSize: 12, color: _c.ink70)), @@ -1689,7 +2221,7 @@ class _ControlPanelState extends State { return ListView( padding: const EdgeInsets.fromLTRB(28, 8, 28, 24), children: [ - _group(title: '软件更新', icon: Icons.system_update_outlined, children: [ + _group(title: '软件更新', icon: FluentIcons.download, children: [ ValueListenableBuilder( valueListenable: appUpdateState, builder: (context, snap, _) => Column( @@ -1705,8 +2237,7 @@ class _ControlPanelState extends State { const SizedBox(height: 12), _switch('检测到新版本时自动下载(下完等你确认重启)', _s.autoDownloadUpdate, (v) { - _s.autoDownloadUpdate = v; - _commit(); + _setConfig('settings.autoDownloadUpdate', v); }), const SizedBox(height: 4), Row(children: [ @@ -1724,11 +2255,11 @@ class _ControlPanelState extends State { ]) GestureDetector( onTap: () { - _s.updateSource = v; - _commit(); + _setConfig('settings.updateSource', v); }, child: AnimatedContainer( duration: const Duration(milliseconds: 160), + curve: Curves.easeOutCubic, padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 5), decoration: BoxDecoration( @@ -1753,7 +2284,7 @@ class _ControlPanelState extends State { style: TextStyle(fontSize: 11, color: _c.ink38, height: 1.5), ), ]), - _group(title: '启动', icon: Icons.power_settings_new_outlined, children: [ + _group(title: '启动', icon: FluentIcons.power_button, children: [ if (_autoStart == null) Padding( padding: const EdgeInsets.only(bottom: 8), @@ -1768,7 +2299,7 @@ class _ControlPanelState extends State { style: TextStyle(fontSize: 11, color: _c.ink38, height: 1.5), ), ]), - _group(title: '日志', icon: Icons.description_outlined, children: [ + _group(title: '日志', icon: FluentIcons.document, children: [ Text( '运行日志按天分文件,保留最近 7 天。\n' '反馈问题时把最近那几个 .log 一并发来,能省掉大量来回确认。', @@ -1780,13 +2311,13 @@ class _ControlPanelState extends State { Button( onPressed: () => NativeBridge.openLogDir(AppPaths.logsDir), child: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.folder_open_outlined, size: 14, color: _c.ink70), + Icon(FluentIcons.open_file, size: 14, color: _c.ink70), const SizedBox(width: 6), Text('打开日志目录', style: TextStyle(fontSize: 12)), ]), ), ]), - _group(title: '布局备份', icon: Icons.backup_outlined, children: [ + _group(title: '布局备份', icon: FluentIcons.save, children: [ Text( '备份包含卡片布局、外观设置和 AI 配置,不含插件缓存,只有几 KB。\n' '换电脑或重装前导出一份,装好之后导入即可恢复原样。', @@ -1797,7 +2328,7 @@ class _ControlPanelState extends State { Button( onPressed: _exportBackup, child: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.file_upload_outlined, size: 14, color: _c.ink70), + Icon(FluentIcons.upload, size: 14, color: _c.ink70), const SizedBox(width: 6), Text('导出备份', style: TextStyle(fontSize: 12)), ]), @@ -1806,7 +2337,7 @@ class _ControlPanelState extends State { Button( onPressed: _importBackup, child: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.file_download_outlined, size: 14, color: _c.ink70), + Icon(FluentIcons.download, size: 14, color: _c.ink70), const SizedBox(width: 6), Text('导入备份', style: TextStyle(fontSize: 12)), ]), @@ -1817,8 +2348,8 @@ class _ControlPanelState extends State { Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( _backupFailed - ? Icons.error_outline - : Icons.check_circle_outline, + ? FluentIcons.error + : FluentIcons.completed, size: 14, color: _backupFailed ? const Color(0xFFE81123) : _c.accentIcon, ), @@ -1985,7 +2516,7 @@ class _ControlPanelState extends State { ), const SizedBox(height: 16), Center( - child: Text('Vectra', + child: Text('Vectra · 桌面组件', style: TextStyle( fontSize: 24, fontWeight: FontWeight.w600, color: _c.ink)), ), @@ -2000,26 +2531,10 @@ class _ControlPanelState extends State { style: TextStyle(fontSize: 12, color: _c.ink38)), ), const SizedBox(height: 28), - _group(title: '信息', icon: Icons.info_outline, children: [ - _aboutRow('作者', 'MacroSTAR Studio © 2026'), + _group(title: '信息', icon: FluentIcons.info, children: [ + _aboutRow('作者', 'KiriharaReina © 2026'), _aboutRow('数据', widget.store.dir, monospace: true), ]), - _group(title: '项目', icon: Icons.link_outlined, children: [ - Padding( - padding: const EdgeInsets.only(top: 2), - child: Row(children: [ - const SizedBox(width: 2), - Icon(Icons.link, size: 14, color: _c.accentIcon), - const SizedBox(width: 10), - HyperlinkButton( - onPressed: () => launchUrl( - Uri.parse('https://github.com/MacroSTAR-Org/Vectra')), - child: Text('github.com/MacroSTAR-Org/Vectra', - style: TextStyle(fontSize: 12, color: _c.accent)), - ), - ]), - ), - ]), ], ); } @@ -2104,48 +2619,59 @@ class _ControlPanelState extends State { ); } + /// 开关行:Win11 设置那样"文字在左、开关在右",不再把文字塞进开关里 Widget _switch(String label, bool value, ValueChanged onChanged) { return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: ToggleSwitch( - checked: value, - onChanged: onChanged, - content: Text(label, - style: TextStyle(fontSize: 12, color: _c.ink70)), - ), + padding: const EdgeInsets.only(bottom: 10), + child: Row(children: [ + Expanded( + child: Text(label, + style: TextStyle(fontSize: 12.5, color: _c.ink70)), + ), + const SizedBox(width: 12), + ToggleSwitch(checked: value, onChanged: onChanged), + ]), ); } - /// 分组卡片:圆角 + 半透明底 + 小节标题 + /// Win11 设置的分组样式:**小标题在卡片外面**(组名 + 可选图标), + /// 下面一张实色圆角卡片装着这一组的所有行。 Widget _group({ required String title, IconData? icon, required List children, }) { return Container( - margin: const EdgeInsets.only(bottom: 14), - padding: const EdgeInsets.fromLTRB(18, 14, 18, 16), - decoration: BoxDecoration( - color: _c.card, - borderRadius: BorderRadius.circular(_kRadius), - border: Border.all(color: _c.cardBorder), - ), + margin: const EdgeInsets.only(bottom: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - if (icon != null) ...[ - Icon(icon, size: 14, color: _c.accentIcon), - const SizedBox(width: 6), - ], - Text(title, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: _c.accentSoft)), - ]), - const SizedBox(height: 12), - ...children, + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 6), + child: Row(children: [ + if (icon != null) ...[ + Icon(icon, size: 13, color: _c.ink54), + const SizedBox(width: 6), + ], + Text(title, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: _c.ink)), + ]), + ), + Container( + padding: const EdgeInsets.fromLTRB(18, 14, 18, 16), + decoration: BoxDecoration( + color: _c.card, + borderRadius: BorderRadius.circular(_kRadius), + border: Border.all(color: _c.cardBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ), + ), ], ), ); diff --git a/lib/ui/panel_app.dart b/lib/ui/panel_app.dart index 71e52a7..39fb72c 100644 --- a/lib/ui/panel_app.dart +++ b/lib/ui/panel_app.dart @@ -68,7 +68,7 @@ class PanelApp extends StatelessWidget { locale: const Locale('zh', 'CN'), theme: FluentThemeData( brightness: light ? Brightness.light : Brightness.dark, - fontFamily: 'TsukushiBMaru', + fontFamily: 'SourceHanSansSC', // 主色沿用磁贴那套天蓝:浅色下加深一档,保证对比度 accentColor: AccentColor.swatch({ 'normal': diff --git a/lib/ui/surface.dart b/lib/ui/surface.dart index fdeeff4..3b86ef8 100644 --- a/lib/ui/surface.dart +++ b/lib/ui/surface.dart @@ -435,6 +435,7 @@ class DesktopSurfaceState extends State { child: CardView( card: card, settings: _settings, + dataDir: widget.store.dir, width: _px(card).w, height: _px(card).h, editing: _editingId == card.id, diff --git a/pubspec.lock b/pubspec.lock index 57aac1e..e0b08fb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,16 +5,16 @@ packages: dependency: "direct main" description: name: archive - sha256: be169cf6ac481e052c4538715d88841d567150dfe1df38aaec76461a4e7b39f2 - url: "https://pub.flutter-io.cn" + sha256: "6c5bcd986e06b94e3c40244af471750840a3d2341d1f9763a1100a14add517b4" + url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "4.3.0" args: dependency: transitive description: name: args sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.7.0" async: @@ -22,7 +22,7 @@ packages: description: name: async sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.13.1" boolean_selector: @@ -30,7 +30,7 @@ packages: description: name: boolean_selector sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.2" characters: @@ -38,39 +38,39 @@ packages: description: name: characters sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.4.1" clock: dependency: transitive description: name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.flutter-io.cn" + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e + url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.3" collection: dependency: transitive description: name: collection sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.19.1" cross_file: dependency: transitive description: name: cross_file - sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" - url: "https://pub.flutter-io.cn" + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" source: hosted - version: "0.3.5+4" + version: "0.3.5+5" crypto: dependency: transitive description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.0.7" cupertino_icons: @@ -78,7 +78,7 @@ packages: description: name: cupertino_icons sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.0.9" dbus: @@ -86,7 +86,7 @@ packages: description: name: dbus sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.7.15" fake_async: @@ -94,7 +94,7 @@ packages: description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.3.3" ffi: @@ -102,7 +102,7 @@ packages: description: name: ffi sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.2.0" file: @@ -110,7 +110,7 @@ packages: description: name: file sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "7.0.1" file_picker: @@ -118,7 +118,7 @@ packages: description: name: file_picker sha256: "29cc1fdb20613876cc7afc529738c1c0f11a9ca159b010edad0c566ac330847e" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "11.0.3" fixnum: @@ -126,7 +126,7 @@ packages: description: name: fixnum sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.1" fluent_ui: @@ -134,7 +134,7 @@ packages: description: name: fluent_ui sha256: "64c2b9dd208f8e3134d3e38db107235ee30c1dd9b792843274687a7ecc063590" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.16.1" flutter: @@ -147,7 +147,7 @@ packages: description: name: flutter_js sha256: "046428059ac7bef71e305dc35fc24be933b684684a43e83eab7ebd0dc407ad85" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.8.7" flutter_lints: @@ -155,7 +155,7 @@ packages: description: name: flutter_lints sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "6.0.0" flutter_localizations: @@ -168,7 +168,7 @@ packages: description: name: flutter_plugin_android_lifecycle sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.0.35" flutter_test: @@ -186,7 +186,7 @@ packages: description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.6.0" http_parser: @@ -194,23 +194,23 @@ packages: description: name: http_parser sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.1.2" intl: dependency: transitive description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" - url: "https://pub.flutter-io.cn" + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" source: hosted - version: "0.20.2" + version: "0.20.3" leak_tracker: dependency: transitive description: name: leak_tracker sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "11.0.2" leak_tracker_flutter_testing: @@ -218,7 +218,7 @@ packages: description: name: leak_tracker_flutter_testing sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.0.10" leak_tracker_testing: @@ -226,7 +226,7 @@ packages: description: name: leak_tracker_testing sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.0.2" lints: @@ -234,23 +234,23 @@ packages: description: name: lints sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "6.1.0" matcher: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 - url: "https://pub.flutter-io.cn" + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: name: material_color_utilities sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.13.0" math_expressions: @@ -258,7 +258,7 @@ packages: description: name: math_expressions sha256: "22aca3a24e445be5f42ecf186cbded1f9951bf9ec5eb48fcddbd55e102bb11f5" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.2.0" menu_base: @@ -266,23 +266,23 @@ packages: description: name: menu_base sha256: "820368014a171bd1241030278e6c2617354f492f5c703d7b7d4570a6b8b84405" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.1.1" meta: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" - url: "https://pub.flutter-io.cn" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" package_info_plus: dependency: "direct main" description: name: package_info_plus sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "8.3.1" package_info_plus_platform_interface: @@ -290,7 +290,7 @@ packages: description: name: package_info_plus_platform_interface sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.2.1" path: @@ -298,23 +298,23 @@ packages: description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.9.1" petitparser: dependency: transitive description: name: petitparser - sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" - url: "https://pub.flutter-io.cn" + sha256: "2f1564aadb14215e478563b5406cb80729d022158c01dc50c96013c2f7eb4790" + url: "https://pub.dev" source: hosted - version: "7.0.2" + version: "7.1.0" plugin_platform_interface: dependency: transitive description: name: plugin_platform_interface sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.8" posix: @@ -322,7 +322,7 @@ packages: description: name: posix sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "6.5.2" recase: @@ -330,7 +330,7 @@ packages: description: name: recase sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.1.0" scroll_pos: @@ -338,7 +338,7 @@ packages: description: name: scroll_pos sha256: cebf602b2dd939de6832bb902ffefb574608d1b84f420b82b381a4007d3c1e1b - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.5.0" sentry: @@ -346,7 +346,7 @@ packages: description: name: sentry sha256: "599701ca0693a74da361bc780b0752e1abc98226cf5095f6b069648116c896bb" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "8.14.2" sentry_flutter: @@ -354,7 +354,7 @@ packages: description: name: sentry_flutter sha256: "5ba2cf40646a77d113b37a07bd69f61bb3ec8a73cbabe5537b05a7c89d2656f8" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "8.14.2" shortid: @@ -362,7 +362,7 @@ packages: description: name: shortid sha256: d0b40e3dbb50497dad107e19c54ca7de0d1a274eb9b4404991e443dadb9ebedb - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.1.2" sky_engine: @@ -375,23 +375,23 @@ packages: description: name: source_span sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.10.2" stack_trace: dependency: transitive description: name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.flutter-io.cn" + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" source: hosted - version: "1.12.1" + version: "1.12.2" stream_channel: dependency: transitive description: name: stream_channel sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.4" string_scanner: @@ -399,7 +399,7 @@ packages: description: name: string_scanner sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.4.1" sync_http: @@ -407,7 +407,7 @@ packages: description: name: sync_http sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.3.1" term_glyph: @@ -415,23 +415,23 @@ packages: description: name: term_glyph sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.2.2" test_api: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" - url: "https://pub.flutter-io.cn" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" tray_manager: dependency: "direct main" description: name: tray_manager sha256: "1a659b08baa6e9b91ef8ce16eda37740de398be1c4cf322b8a1ddfef25c68c5a" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.5.3" typed_data: @@ -439,7 +439,7 @@ packages: description: name: typed_data sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.4.0" url_launcher: @@ -447,47 +447,47 @@ packages: description: name: url_launcher sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "6.3.2" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 - url: "https://pub.flutter-io.cn" + sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610" + url: "https://pub.dev" source: hosted - version: "6.3.32" + version: "6.3.33" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" - url: "https://pub.flutter-io.cn" + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" + url: "https://pub.dev" source: hosted - version: "6.4.1" + version: "6.4.2" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a - url: "https://pub.flutter-io.cn" + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" + url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.2.3" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" - url: "https://pub.flutter-io.cn" + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" + url: "https://pub.dev" source: hosted - version: "3.2.5" + version: "3.2.6" url_launcher_platform_interface: dependency: transitive description: name: url_launcher_platform_interface sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.3.2" url_launcher_web: @@ -495,39 +495,39 @@ packages: description: name: url_launcher_web sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.4.3" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" - url: "https://pub.flutter-io.cn" + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" + url: "https://pub.dev" source: hosted - version: "3.1.5" + version: "3.1.6" uuid: dependency: transitive description: name: uuid sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.6.0" vector_math: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.flutter-io.cn" + sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d" + url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.3" vm_service: dependency: transitive description: name: vm_service sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "15.3.0" web: @@ -535,7 +535,7 @@ packages: description: name: web sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.1" win32: @@ -543,17 +543,17 @@ packages: description: name: win32 sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "5.15.0" xml: dependency: transitive description: name: xml - sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" - url: "https://pub.flutter-io.cn" + sha256: "0fe8cc98946def31cbcbc8734aa4067031e6f65ea3a78a508060bfed2200bfeb" + url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.1.0" sdks: - dart: ">=3.12.2 <4.0.0" + dart: ">=3.13.0 <4.0.0" flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index b858a38..24b23d8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -75,26 +75,11 @@ flutter: - assets/plugins/weather/ - assets/plugins/lyrics/ - # 鍏ㄥ眬瀛椾綋锛氶缚钂?HarmonyOS Sans SC + # 全局中文界面字体:思源黑体简体中文可变字体 fonts: - - family: HarmonyOS Sans SC + - family: SourceHanSansSC fonts: - - asset: lib/fonts/HarmonyOS_Sans_SC_Thin.ttf - weight: 100 - - asset: lib/fonts/HarmonyOS_Sans_SC_Light.ttf - weight: 300 - - asset: lib/fonts/HarmonyOS_Sans_SC_Regular.ttf - weight: 400 - - asset: lib/fonts/HarmonyOS_Sans_SC_Medium.ttf - weight: 500 - - asset: lib/fonts/HarmonyOS_Sans_SC_Bold.ttf - weight: 700 - - asset: lib/fonts/HarmonyOS_Sans_SC_Black.ttf - weight: 900 - # 时钟专用字体:筑紫B丸新英数(圆体),只给时钟插件用 - - family: TsukushiBMaru - fonts: - - asset: assets/fonts/tsukushi_b_maru.ttf + - asset: lib/fonts/SourceHanSansSC-VF.ttf # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in diff --git a/test/config_binding_test.dart b/test/config_binding_test.dart new file mode 100644 index 0000000..360186b --- /dev/null +++ b/test/config_binding_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:vectra/config/config_binding.dart'; + +void main() { + test('binding validates and writes typed values', () { + var value = 10; + final binding = ConfigBinding(ConfigSpec( + key: 'settings.gridCell', + scope: ConfigScope.app, + type: ConfigValueType.number, + min: 8, + max: 20, + step: 2, + read: () => value, + write: (next) => value = next, + )); + + binding.set(14); + expect(value, 14); + expect(() => binding.set(15), throwsA(isA())); + expect(() => binding.set(22), throwsA(isA())); + }); + + test('select binding rejects values outside registered options', () { + var value = 'auto'; + final binding = ConfigBinding(ConfigSpec( + key: 'settings.updateSource', + scope: ConfigScope.app, + type: ConfigValueType.select, + options: const [ + ConfigOption(value: 'auto', label: '自动'), + ConfigOption(value: 'github', label: 'GitHub'), + ], + read: () => value, + write: (next) => value = next, + )); + + binding.set('github'); + expect(value, 'github'); + expect(() => binding.set('unknown'), + throwsA(isA())); + }); + + test('registry rejects duplicate keys', () { + final registry = ConfigRegistry(); + ConfigSpec spec(bool value) => ConfigSpec( + key: 'settings.enabled', + scope: ConfigScope.app, + type: ConfigValueType.boolean, + read: () => value, + write: (_) {}, + ); + + registry.register(spec(true)); + expect(() => registry.register(spec(false)), throwsArgumentError); + }); +}