diff --git a/lib/common/widgets/loading_widget/loading_widget.dart b/lib/common/widgets/loading_widget/loading_widget.dart index 9be42fa19..026758898 100644 --- a/lib/common/widgets/loading_widget/loading_widget.dart +++ b/lib/common/widgets/loading_widget/loading_widget.dart @@ -1,6 +1,9 @@ import 'package:PiliPlus/common/widgets/loading_widget/http_error.dart'; +import 'package:PiliPlus/common/widgets/loading_widget/m3e_loading_indicator.dart'; import 'package:flutter/material.dart'; +const Widget m3eLoading = Center(child: M3ELoadingIndicator()); + const Widget circularLoading = Center(child: CircularProgressIndicator()); const Widget linearLoading = SliverToBoxAdapter( diff --git a/lib/common/widgets/loading_widget/m3e_loading_indicator.dart b/lib/common/widgets/loading_widget/m3e_loading_indicator.dart new file mode 100644 index 000000000..03f89da89 --- /dev/null +++ b/lib/common/widgets/loading_widget/m3e_loading_indicator.dart @@ -0,0 +1,244 @@ +/* + * This file is part of PiliPlus + * + * PiliPlus is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * PiliPlus is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with PiliPlus. If not, see . + */ + +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart' show SpringSimulation; +import 'package:material_new_shapes/material_new_shapes.dart'; + +/// reimplement of https://github.com/EmilyMoonstone/material_3_expressive/tree/main/packages/loading_indicator_m3e + +class M3ELoadingIndicator extends StatefulWidget { + const M3ELoadingIndicator({super.key}); + + @override + State createState() => _M3ELoadingIndicatorState(); +} + +class _M3ELoadingIndicatorState extends State + with SingleTickerProviderStateMixin { + static final List _morphs = () { + final List shapes = [ + MaterialShapes.softBurst, + MaterialShapes.cookie9Sided, + MaterialShapes.pentagon, + MaterialShapes.pill, + MaterialShapes.sunny, + MaterialShapes.cookie4Sided, + MaterialShapes.oval, + ]; + return [ + for (var i = 0; i < shapes.length; i++) + Morph( + shapes[i], + shapes[(i + 1) % shapes.length], + ), + ]; + }(); + + static const int _morphIntervalMs = 650; + static const double _fullRotation = 360.0; + static const int _globalRotationDurationMs = 4666; + static const double _quarterRotation = _fullRotation / 4; + + late final AnimationController _controller; + + int _morphIndex = 1; + + double _morphRotationTargetAngle = _quarterRotation; + + final _morphAnimationSpec = SpringSimulation( + SpringDescription.withDampingRatio(ratio: 0.6, stiffness: 200.0, mass: 1.0), + 0.0, + 1.0, + 5.0, + snapToEnd: true, + ); + + void _statusListener(AnimationStatus status) { + if (status == AnimationStatus.completed) { + _startAnimation(); + } + } + + void _startAnimation() { + _morphIndex++; + _morphRotationTargetAngle = + (_morphRotationTargetAngle + _quarterRotation) % _fullRotation; + _controller + ..value = 0.0 + ..animateWith(_morphAnimationSpec); + } + + @override + void initState() { + super.initState(); + _controller = + AnimationController( + vsync: this, + duration: const Duration(milliseconds: _morphIntervalMs), + ) + ..addStatusListener(_statusListener) + ..value = 0.0 + ..animateWith(_morphAnimationSpec); + } + + @override + void dispose() { + _controller + ..removeStatusListener(_statusListener) + ..dispose(); + super.dispose(); + } + + double _calcAngle(double progress) { + final elapsedInMs = + _morphIntervalMs * (_morphIndex - 1) + + (_controller.lastElapsedDuration?.inMilliseconds ?? 0); + final globalRotationControllerValue = + (elapsedInMs % _globalRotationDurationMs) / _globalRotationDurationMs; + final globalRotationDegrees = globalRotationControllerValue * _fullRotation; + final totalRotationDegrees = + progress * _quarterRotation + + _morphRotationTargetAngle + + globalRotationDegrees; + return totalRotationDegrees * (math.pi / 180.0); + } + + @override + Widget build(BuildContext context) { + final color = Theme.of(context).colorScheme.secondaryFixedDim; + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final progress = _controller.value; + return _M3ELoadingIndicator( + morph: _morphs[_morphIndex % _morphs.length], + progress: progress, + angle: _calcAngle(progress), + color: color, + ); + }, + ); + } +} + +class _M3ELoadingIndicator extends LeafRenderObjectWidget { + const _M3ELoadingIndicator({ + required this.morph, + required this.progress, + required this.angle, + required this.color, + }); + + final Morph morph; + + final double progress; + + final double angle; + + final Color color; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderM3ELoadingIndicator( + morph: morph, + progress: progress, + angle: angle, + color: color, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _RenderM3ELoadingIndicator renderObject, + ) { + renderObject + ..morph = morph + ..progress = progress + ..angle = angle + ..color = color; + } +} + +class _RenderM3ELoadingIndicator extends RenderBox { + _RenderM3ELoadingIndicator({ + required Morph morph, + required double progress, + required double angle, + required Color color, + }) : _morph = morph, + _progress = progress, + _angle = angle, + _color = color, + _paint = Paint() + ..style = PaintingStyle.fill + ..color = color; + + Morph _morph; + Morph get morph => _morph; + set morph(Morph value) { + if (_morph == value) return; + _morph = value; + markNeedsPaint(); + } + + double _progress; + double get progress => _progress; + set progress(double value) { + if (_progress == value) return; + _progress = value; + markNeedsPaint(); + } + + double _angle; + double get angle => _angle; + set angle(double value) { + if (_angle == value) return; + _angle = value; + markNeedsPaint(); + } + + Color _color; + final Paint _paint; + set color(Color value) { + if (_color == value) return; + _paint.color = _color = value; + markNeedsPaint(); + } + + @override + void performLayout() { + size = constraints.constrainDimensions(40, 40); + } + + @override + void paint(PaintingContext context, Offset offset) { + final width = size.width; + final value = size.width / 2; + final matrix = Matrix4.identity() + ..translateByDouble(offset.dx + value, offset.dy + value, 0.0, 1.0) + ..rotateZ(angle) + ..translateByDouble(-value, -value, 0.0, 1.0) + ..scaleByDouble(width, width, width, 1.0); + final path = morph.toPath(progress: progress).transform(matrix.storage); + + context.canvas.drawPath(path, _paint); + } +} diff --git a/lib/pages/dynamics_select_topic/view.dart b/lib/pages/dynamics_select_topic/view.dart index 240a7d9c8..1d9bd60c1 100644 --- a/lib/pages/dynamics_select_topic/view.dart +++ b/lib/pages/dynamics_select_topic/view.dart @@ -188,7 +188,7 @@ class _SelectTopicPanelState LoadingState?> loadingState, ) { return switch (loadingState) { - Loading() => circularLoading, + Loading() => m3eLoading, Success?>(:final response) => response != null && response.isNotEmpty ? ListView.builder( diff --git a/lib/pages/emote/view.dart b/lib/pages/emote/view.dart index 9a4061d25..8e8a35f94 100644 --- a/lib/pages/emote/view.dart +++ b/lib/pages/emote/view.dart @@ -50,7 +50,7 @@ class _EmotePanelState extends State Get.currentRoute.startsWith('/whisperDetail') ? 8 : 2, ); return switch (loadingState) { - Loading() => circularLoading, + Loading() => m3eLoading, Success(:final response) => response != null && response.isNotEmpty ? Column( diff --git a/lib/pages/fav/topic/view.dart b/lib/pages/fav/topic/view.dart index 0941615e3..ce76d2dcc 100644 --- a/lib/pages/fav/topic/view.dart +++ b/lib/pages/fav/topic/view.dart @@ -3,7 +3,7 @@ import 'package:PiliPlus/common/widgets/dialog/dialog.dart'; import 'package:PiliPlus/common/widgets/flutter/refresh_indicator.dart'; import 'package:PiliPlus/common/widgets/loading_widget/http_error.dart'; import 'package:PiliPlus/common/widgets/loading_widget/loading_widget.dart' - show circularLoading; + show m3eLoading; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/models_new/fav/fav_topic/topic_item.dart'; import 'package:PiliPlus/pages/fav/topic/controller.dart'; @@ -68,7 +68,7 @@ class _FavTopicPageState extends State Loading() => const SliverToBoxAdapter( child: SizedBox( height: 125, - child: circularLoading, + child: m3eLoading, ), ), Success(:final response) => diff --git a/lib/pages/fav_create/view.dart b/lib/pages/fav_create/view.dart index c2febfccc..efea2da1b 100644 --- a/lib/pages/fav_create/view.dart +++ b/lib/pages/fav_create/view.dart @@ -107,7 +107,7 @@ class _CreateFavPageState extends State { ? _buildBody(theme) : _errMsg?.isNotEmpty == true ? scrollErrorWidget(errMsg: _errMsg, onReload: _getFolderInfo) - : circularLoading + : m3eLoading : _buildBody(theme), ); } diff --git a/lib/pages/fav_panel/view.dart b/lib/pages/fav_panel/view.dart index 605a17352..bb2fc822f 100644 --- a/lib/pages/fav_panel/view.dart +++ b/lib/pages/fav_panel/view.dart @@ -41,7 +41,7 @@ class _FavPanelState extends State { Widget get _buildBody { late final list = widget.ctr.favFolderData.value.list!; return switch (loadingState) { - Loading() => circularLoading, + Loading() => m3eLoading, Success() => ListView.builder( controller: widget.scrollController, itemCount: list.length, diff --git a/lib/pages/follow/view.dart b/lib/pages/follow/view.dart index 2086c8b3c..745a21fcd 100644 --- a/lib/pages/follow/view.dart +++ b/lib/pages/follow/view.dart @@ -109,7 +109,7 @@ class _FollowPageState extends State { Widget _buildBody(LoadingState loadingState) { return switch (loadingState) { - Loading() => circularLoading, + Loading() => m3eLoading, Success() => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/pages/group_panel/view.dart b/lib/pages/group_panel/view.dart index e4a8dab70..d100de257 100644 --- a/lib/pages/group_panel/view.dart +++ b/lib/pages/group_panel/view.dart @@ -68,7 +68,7 @@ class _GroupPanelState extends State { Widget get _buildBody { return switch (loadingState) { - Loading() => circularLoading, + Loading() => m3eLoading, Success(:final response) => ListView.builder( controller: widget.scrollController, itemCount: response.length, diff --git a/lib/pages/live_emote/view.dart b/lib/pages/live_emote/view.dart index bf9c4521e..3b92439cd 100644 --- a/lib/pages/live_emote/view.dart +++ b/lib/pages/live_emote/view.dart @@ -57,7 +57,7 @@ class _LiveEmotePanelState extends State 2, ); return switch (loadingState) { - Loading() => circularLoading, + Loading() => m3eLoading, Success(:final response) => response != null && response.isNotEmpty ? Column( diff --git a/lib/pages/login/view.dart b/lib/pages/login/view.dart index 1cd33a509..01793e02f 100644 --- a/lib/pages/login/view.dart +++ b/lib/pages/login/view.dart @@ -102,7 +102,7 @@ class _LoginPageState extends State { Loading() => const SizedBox( height: 200, width: 200, - child: circularLoading, + child: m3eLoading, ), Success(:final response) => Container( width: 200, diff --git a/lib/pages/member/view.dart b/lib/pages/member/view.dart index df99ee141..db2328cac 100644 --- a/lib/pages/member/view.dart +++ b/lib/pages/member/view.dart @@ -68,7 +68,7 @@ class _MemberPageState extends State { color: theme.surface, child: Obx( () => switch (_userController.loadingState.value) { - Loading() => circularLoading, + Loading() => m3eLoading, Success(:final response) => ExtendedNestedScrollView( key: _userController.key, onlyOneScrollInBody: true, diff --git a/lib/pages/member_home/view.dart b/lib/pages/member_home/view.dart index 30e86ff87..150b02a52 100644 --- a/lib/pages/member_home/view.dart +++ b/lib/pages/member_home/view.dart @@ -73,7 +73,7 @@ class _MemberHomeState extends State final isOwner = setting != null; final color = Theme.of(context).colorScheme.outline; return switch (loadingState) { - Loading() => circularLoading, + Loading() => m3eLoading, Success(response: final res) => res != null ? CustomScrollView( diff --git a/lib/pages/member_profile/view.dart b/lib/pages/member_profile/view.dart index cb814634a..fa4082b48 100644 --- a/lib/pages/member_profile/view.dart +++ b/lib/pages/member_profile/view.dart @@ -127,7 +127,7 @@ class _EditProfilePageState extends State { ); return switch (loadingState) { - Loading() => circularLoading, + Loading() => m3eLoading, Success(:final response) => ListView( padding: EdgeInsets.only( bottom: MediaQuery.viewPaddingOf(context).bottom + 25, diff --git a/lib/pages/pgc/view.dart b/lib/pages/pgc/view.dart index e6d14831b..8beed926d 100644 --- a/lib/pages/pgc/view.dart +++ b/lib/pages/pgc/view.dart @@ -83,7 +83,7 @@ class _PgcPageState extends State with AutomaticKeepAliveClientMixin { ThemeData theme, LoadingState?> loadingState, ) => switch (loadingState) { - Loading() => circularLoading, + Loading() => m3eLoading, Success(:final response) => response != null && response.isNotEmpty ? Builder( @@ -397,7 +397,7 @@ class _PgcPageState extends State with AutomaticKeepAliveClientMixin { Widget _buildFollowBody(LoadingState?> loadingState) { return switch (loadingState) { - Loading() => circularLoading, + Loading() => m3eLoading, Success(:final response) => response != null && response.isNotEmpty ? ListView.builder( diff --git a/lib/pages/pgc_index/view.dart b/lib/pages/pgc_index/view.dart index 7ddc656d8..cf027ecb8 100644 --- a/lib/pages/pgc_index/view.dart +++ b/lib/pages/pgc_index/view.dart @@ -58,7 +58,7 @@ class _PgcIndexPageState extends State ) { final padding = MediaQuery.viewPaddingOf(context); return switch (loadingState) { - Loading() => circularLoading, + Loading() => m3eLoading, Success(:final response) => Builder( builder: (context) { int count = diff --git a/lib/pages/video/member/view.dart b/lib/pages/video/member/view.dart index 8b4fb21c9..26105ac74 100644 --- a/lib/pages/video/member/view.dart +++ b/lib/pages/video/member/view.dart @@ -81,7 +81,7 @@ class _HorizontalMemberPageState extends State { Widget _buildUserPage(ThemeData theme, LoadingState userState) { return switch (userState) { - Loading() => circularLoading, + Loading() => m3eLoading, Success(:final response) => Column( children: [ _buildUserInfo(theme, response), diff --git a/lib/pages/whisper_block/view.dart b/lib/pages/whisper_block/view.dart index 4bda0332e..96ec9f74f 100644 --- a/lib/pages/whisper_block/view.dart +++ b/lib/pages/whisper_block/view.dart @@ -37,7 +37,7 @@ class _WhisperBlockPageState extends State { LoadingState?> loadingState, ) { return switch (loadingState) { - Loading() => circularLoading, + Loading() => m3eLoading, Success(:final response) => response != null && response.isNotEmpty ? Column( diff --git a/lib/pages/whisper_detail/view.dart b/lib/pages/whisper_detail/view.dart index 162f7e3c8..d04c55196 100644 --- a/lib/pages/whisper_detail/view.dart +++ b/lib/pages/whisper_detail/view.dart @@ -151,7 +151,7 @@ class _WhisperDetailPageState Widget _buildBody(LoadingState?> loadingState) { return switch (loadingState) { - Loading() => circularLoading, + Loading() => m3eLoading, Success(:final response) => response != null && response.isNotEmpty ? ChatListView.separated( diff --git a/pubspec.lock b/pubspec.lock index 37b943285..b2c849251 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1127,6 +1127,14 @@ packages: url: "https://github.com/bggRGjQaUbCoE/material_design_icons_flutter.git" source: git version: "7.0.7447" + material_new_shapes: + dependency: "direct main" + description: + name: material_new_shapes + sha256: e4bc375205e187e8fb232573387112dd8c0dd45b03af8aa2b3c79eb4b9e3e0dc + url: "https://pub.dev" + source: hosted + version: "1.0.0" media_kit: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index a21df530a..e2e8c6d88 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -227,6 +227,7 @@ dependencies: ref: mod dlna_dart: ^0.1.0 battery_plus: ^7.0.0 + material_new_shapes: ^1.0.0 vector_math: any fixnum: any