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