import 'package:flutter/material.dart'; import 'data/api/report_data_source.dart'; import 'features/shell_page.dart'; import 'theme/app_theme.dart'; class MyApp extends StatelessWidget { const MyApp({required this.dataSource, super.key}); final ReportDataSource dataSource; @override Widget build(BuildContext context) { return MaterialApp( title: '研听', debugShowCheckedModeBanner: false, theme: buildAppTheme(), scrollBehavior: const WhitespaceStretchScrollBehavior(), home: ShellPage(dataSource: dataSource), ); } } class WhitespaceStretchScrollBehavior extends MaterialScrollBehavior { const WhitespaceStretchScrollBehavior(); @override Widget buildOverscrollIndicator( BuildContext context, Widget child, ScrollableDetails details, ) { return _WhitespaceStretchIndicator(child: child); } } class _WhitespaceStretchIndicator extends StatefulWidget { const _WhitespaceStretchIndicator({required this.child}); final Widget child; @override State<_WhitespaceStretchIndicator> createState() => _WhitespaceStretchIndicatorState(); } class _WhitespaceStretchIndicatorState extends State<_WhitespaceStretchIndicator> with SingleTickerProviderStateMixin { static const double _maxStretch = 64; static const double _dragResistance = 0.38; late final AnimationController _offsetController = AnimationController.unbounded(vsync: this)..addListener(_onTick); @override Widget build(BuildContext context) { return NotificationListener( onNotification: _handleScrollNotification, child: ClipRect( child: Transform.translate( offset: Offset(0, _offsetController.value), child: widget.child, ), ), ); } @override void dispose() { _offsetController.dispose(); super.dispose(); } bool _handleScrollNotification(ScrollNotification notification) { if (notification.metrics.axis != Axis.vertical) { return false; } if (notification is OverscrollNotification) { final overscroll = notification.overscroll; final atTop = notification.metrics.pixels <= notification.metrics.minScrollExtent; final atBottom = notification.metrics.pixels >= notification.metrics.maxScrollExtent; if (atTop && overscroll < 0) { _setOffset( (_offsetController.value - overscroll * _dragResistance).clamp( 0, _maxStretch, ), ); } else if (atBottom && overscroll > 0) { _setOffset( (_offsetController.value - overscroll * _dragResistance).clamp( -_maxStretch, 0, ), ); } } if (notification is ScrollUpdateNotification && notification.dragDetails == null) { _releaseOffset(); } if (notification is ScrollEndNotification) { _releaseOffset(); } return false; } void _setOffset(num next) { if (next == _offsetController.value) { return; } _offsetController.stop(); _offsetController.value = next.toDouble(); } void _releaseOffset() { if (_offsetController.value == 0) { return; } _offsetController.animateTo( 0, duration: const Duration(milliseconds: 260), curve: Curves.easeOutCubic, ); } void _onTick() { if (mounted) { setState(() {}); } } }