fix: automate graduate timetable read and refine blue glass Dock

This commit is contained in:
Codex
2026-10-13 17:50:04 +08:00
parent 8f20a341c4
commit 476ef097ee
23 changed files with 1072 additions and 176 deletions
+110 -76
View File
@@ -4,6 +4,8 @@ import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'glass_refraction.dart';
class GlassDock extends StatefulWidget {
const GlassDock({
super.key,
@@ -17,21 +19,45 @@ class GlassDock extends StatefulWidget {
}
class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
static const _width = 280.0, _travel = 88.0;
static const _width = 264.0, _height = 68.0, _inset = 3.0, _travel = 86.0;
static const _blue = Color(0xFF007AFF);
static const _selectedInk = Color(0xFF62666C);
late final AnimationController _motion = AnimationController.unbounded(
vsync: this,
value: widget.selectedIndex.toDouble(),
);
late final AnimationController _press = AnimationController(
late final AnimationController _press = AnimationController.unbounded(
vsync: this,
duration: const Duration(milliseconds: 140),
value: 0,
);
bool _reduceMotion = false;
double get _pressure => _press.value.clamp(0.0, 1.18);
void _pressTo(double target) {
if (_reduceMotion) {
_press.value = target;
return;
}
_press.animateWith(
SpringSimulation(
const SpringDescription(mass: 1, stiffness: 440, damping: 20),
_press.value,
target,
_press.velocity,
),
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_reduceMotion = MediaQuery.disableAnimationsOf(context);
}
double? _drag;
double _dragSpeed = 0;
double get _position => _drag ?? _motion.value.clamp(-0.12, 2.12);
void _spring(int index, [double velocity = 0]) {
if (MediaQuery.disableAnimationsOf(context)) {
if (_reduceMotion) {
_motion.value = index.toDouble();
return;
}
@@ -50,7 +76,7 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
setState(() => _drag = null);
_motion.value = position;
_spring(index, velocity);
_press.reverse();
_pressTo(0);
widget.onSelected(index);
}
@@ -69,53 +95,59 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
super.dispose();
}
Widget _glass({required Widget child, bool lens = false}) => ClipRRect(
borderRadius: BorderRadius.circular(lens ? 36 : 40),
child: BackdropFilter(
// Keep the underlying timetable visible. Optical rims provide the glass
// thickness instead of a frosted, heavily tinted fill.
filter: ui.ImageFilter.blur(
sigmaX: lens ? 0.6 : 1.5,
sigmaY: lens ? 0.6 : 1.5,
),
child: CustomPaint(
foregroundPainter: _GlassRimPainter(lens: lens),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: lens
? [
Colors.white.withValues(alpha: 0.045),
_blue.withValues(alpha: 0.018),
Colors.white.withValues(alpha: 0.012),
]
: [
Colors.white.withValues(alpha: 0.028),
_blue.withValues(alpha: 0.005),
Colors.white.withValues(alpha: 0.012),
],
),
Widget _glass({required Widget child, bool lens = false}) {
final pressure = _pressure.clamp(0.0, 1.0);
final content = CustomPaint(
foregroundPainter: _GlassRimPainter(lens: lens),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: lens
? [
const Color(0xFFD8DBE0)
.withValues(alpha: 0.72 * (1 - pressure * 0.91)),
const Color(0xFFC3C7CE)
.withValues(alpha: 0.60 * (1 - pressure * 0.93)),
const Color(0xFFE5E7EB)
.withValues(alpha: 0.72 * (1 - pressure * 0.90)),
]
: [
Colors.white.withValues(alpha: 0.028),
_blue.withValues(alpha: 0.005),
Colors.white.withValues(alpha: 0.012),
],
),
child: child,
),
child: child,
),
),
);
);
return ClipRRect(
borderRadius: BorderRadius.circular(40),
child: lens
? GlassRefraction(strength: 0.25 + pressure * 0.95, child: content)
: BackdropFilter(
filter: ui.ImageFilter.blur(sigmaX: 1.0, sigmaY: 1.0),
child: content,
),
);
}
@override
Widget build(BuildContext context) => AnimatedBuilder(
animation: Listenable.merge([_motion, _press]),
builder: (context, _) {
final press = Curves.easeOut.transform(_press.value);
final press = _pressure;
final speed = (_drag != null ? _dragSpeed : _motion.velocity).abs();
final stretch = (speed / 16).clamp(0.0, 0.14) + press * 0.04;
final lensWidth = 88 * (1 + stretch);
final lensHeight = 56 + press * 5 - stretch * 9;
final stretch = (speed / 16).clamp(0.0, 0.12) + press * 0.18;
final lensWidth = _travel * (1 + stretch);
final lensHeight =
(_height - _inset * 2) *
(1 + press * 0.19 - (speed / 16).clamp(0.0, 0.07));
return SizedBox(
width: _width,
height: 70,
height: _height,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(40),
@@ -127,34 +159,34 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
),
],
),
child: _glass(
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 8 + _position * _travel - (lensWidth - 88) / 2,
top: (70 - lensHeight) / 2,
width: lensWidth,
height: lensHeight,
child: IgnorePointer(
child: _glass(lens: true, child: const SizedBox.expand()),
),
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(child: _glass(child: const SizedBox.expand())),
Positioned(
left: (_inset + _position * _travel - (lensWidth - _travel) / 2)
.clamp(1.0, _width - lensWidth - 1),
top: (_height - lensHeight) / 2,
width: lensWidth,
height: lensHeight,
child: IgnorePointer(
child: _glass(lens: true, child: const SizedBox.expand()),
),
Positioned(
left: 8,
right: 8,
top: 7,
bottom: 7,
child: Row(
children: [
_item(0, Icons.calendar_month_rounded, '课表'),
_item(1, Icons.assignment_outlined, '考试'),
_item(2, Icons.person_rounded, '个人设置'),
],
),
),
Positioned(
left: _inset,
right: _inset,
top: _inset,
bottom: _inset,
child: Row(
children: [
_item(0, Icons.calendar_month_rounded, '课表'),
_item(1, Icons.assignment_outlined, '考试'),
_item(2, Icons.person_rounded, '个人设置'),
],
),
],
),
),
],
),
),
);
@@ -170,16 +202,16 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
child: GestureDetector(
key: index == widget.selectedIndex ? const Key('dock-cursor') : null,
behavior: HitTestBehavior.opaque,
onTapDown: (_) => _press.forward(),
onTapDown: (_) => _pressTo(1),
onTapCancel: () {
if (_drag == null) _press.reverse();
if (_drag == null) _pressTo(0);
},
onTap: () => _select(index),
onHorizontalDragStart: index == widget.selectedIndex
? (_) {
final start = _position.clamp(0.0, 2.0);
_motion.stop();
_press.forward();
_pressTo(1);
setState(() {
_drag = start;
_dragSpeed = 0;
@@ -203,24 +235,25 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
: null,
onHorizontalDragCancel: index == widget.selectedIndex
? () {
if (!mounted) return;
final position = _position;
setState(() => _drag = null);
_motion.value = position;
_spring(widget.selectedIndex);
_press.reverse();
_pressTo(0);
}
: null,
child: Center(
child: AnimatedScale(
scale: index == _position.round().clamp(0, 2)
? 1 + _press.value * 0.08
? 1 + _pressure * 0.08
: 1,
duration: const Duration(milliseconds: 130),
child: Icon(
icon,
size: 27,
color: index == _position.round().clamp(0, 2)
? _blue
? _selectedInk
: _blue.withValues(alpha: 0.48),
),
),
@@ -238,6 +271,7 @@ class _GlassRimPainter extends CustomPainter {
static const _blue = Color(0xFF007AFF);
@override
void paint(Canvas canvas, Size size) {
final edge = lens ? const Color(0xFF7B838F) : _blue;
final bounds = Offset.zero & size;
final outer = RRect.fromRectAndRadius(
bounds.deflate(0.7),
@@ -256,10 +290,10 @@ class _GlassRimPainter extends CustomPainter {
bounds.center,
[
Colors.white.withValues(alpha: 0.90),
_blue.withValues(alpha: lens ? 0.30 : 0.15),
edge.withValues(alpha: lens ? 0.30 : 0.15),
Colors.white.withValues(alpha: 0.10),
Colors.white.withValues(alpha: 0.86),
_blue.withValues(alpha: 0.12),
edge.withValues(alpha: 0.12),
Colors.white.withValues(alpha: 0.90),
],
[0.0, 0.18, 0.38, 0.60, 0.80, 1.0],
@@ -278,7 +312,7 @@ class _GlassRimPainter extends CustomPainter {
bounds.bottomRight,
[
Colors.white.withValues(alpha: 0.30),
_blue.withValues(alpha: lens ? 0.13 : 0.07),
edge.withValues(alpha: lens ? 0.13 : 0.07),
Colors.white.withValues(alpha: 0.40),
],
[0.0, 0.5, 1.0],
+115
View File
@@ -0,0 +1,115 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
/// Filters live pixels beneath the glass; symbols above it remain undistorted.
class GlassRefraction extends StatefulWidget {
const GlassRefraction({
super.key,
required this.strength,
required this.child,
});
final double strength;
final Widget child;
@override
State<GlassRefraction> createState() => _GlassRefractionState();
}
class _GlassRefractionState extends State<GlassRefraction> {
static Future<ui.FragmentProgram>? _program;
ui.FragmentShader? _shader;
@override
void initState() {
super.initState();
if (ui.ImageFilter.isShaderFilterSupported) _load();
}
Future<void> _load() async {
try {
final program = await (_program ??= ui.FragmentProgram.fromAsset(
'shaders/liquid_glass.frag',
));
if (mounted) setState(() => _shader = program.fragmentShader());
} catch (error) {
_program = null;
debugPrint('Glass refraction unavailable: $error');
}
}
@override
void dispose() {
_shader?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => _shader == null
? BackdropFilter(
filter: ui.ImageFilter.blur(sigmaX: 0.6, sigmaY: 0.6),
child: widget.child,
)
: _LiveGlassFilter(
shader: _shader!,
strength: widget.strength,
pixelRatio: MediaQuery.devicePixelRatioOf(context),
child: widget.child,
);
}
class _LiveGlassFilter extends SingleChildRenderObjectWidget {
const _LiveGlassFilter({
required this.shader,
required this.strength,
required this.pixelRatio,
required super.child,
});
final ui.FragmentShader shader;
final double strength, pixelRatio;
@override
_RenderLiveGlass createRenderObject(BuildContext context) =>
_RenderLiveGlass(shader, strength, pixelRatio);
@override
void updateRenderObject(
BuildContext context,
_RenderLiveGlass renderObject,
) => renderObject.update(shader, strength, pixelRatio);
}
class _RenderLiveGlass extends RenderProxyBox {
_RenderLiveGlass(this._shader, this._strength, this._pixelRatio);
ui.FragmentShader _shader;
double _strength, _pixelRatio;
final _layer = LayerHandle<BackdropFilterLayer>();
void update(ui.FragmentShader shader, double strength, double pixelRatio) {
_shader = shader;
_strength = strength;
_pixelRatio = pixelRatio;
markNeedsPaint();
}
@override
bool get alwaysNeedsCompositing => child != null;
@override
void paint(PaintingContext context, Offset offset) {
if (child == null) return;
// Sample coordinates are physical pixels. Resolve the actual transform
// during paint so the glass also follows the Dock's hide/show animation.
final origin = localToGlobal(Offset.zero) * _pixelRatio;
_shader
..setFloat(2, origin.dx)
..setFloat(3, origin.dy)
..setFloat(4, size.width * _pixelRatio)
..setFloat(5, size.height * _pixelRatio)
..setFloat(6, _strength);
_layer.layer ??= BackdropFilterLayer();
_layer.layer!.filter = ui.ImageFilter.shader(_shader);
context.pushLayer(_layer.layer!, super.paint, offset);
}
@override
void dispose() {
_layer.layer = null;
super.dispose();
}
}