m3e loading indicator

Signed-off-by: dom <githubaccount56556@proton.me>
This commit is contained in:
dom
2026-03-17 22:02:26 +08:00
parent d1497115da
commit 9b1ae39922
21 changed files with 275 additions and 19 deletions

View File

@@ -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(

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<M3ELoadingIndicator> createState() => _M3ELoadingIndicatorState();
}
class _M3ELoadingIndicatorState extends State<M3ELoadingIndicator>
with SingleTickerProviderStateMixin {
static final List<Morph> _morphs = () {
final List<RoundedPolygon> 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);
}
}

View File

@@ -188,7 +188,7 @@ class _SelectTopicPanelState
LoadingState<List<TopicItem>?> loadingState,
) {
return switch (loadingState) {
Loading() => circularLoading,
Loading() => m3eLoading,
Success<List<TopicItem>?>(:final response) =>
response != null && response.isNotEmpty
? ListView.builder(

View File

@@ -50,7 +50,7 @@ class _EmotePanelState extends State<EmotePanel>
Get.currentRoute.startsWith('/whisperDetail') ? 8 : 2,
);
return switch (loadingState) {
Loading() => circularLoading,
Loading() => m3eLoading,
Success(:final response) =>
response != null && response.isNotEmpty
? Column(

View File

@@ -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<FavTopicPage>
Loading() => const SliverToBoxAdapter(
child: SizedBox(
height: 125,
child: circularLoading,
child: m3eLoading,
),
),
Success(:final response) =>

View File

@@ -107,7 +107,7 @@ class _CreateFavPageState extends State<CreateFavPage> {
? _buildBody(theme)
: _errMsg?.isNotEmpty == true
? scrollErrorWidget(errMsg: _errMsg, onReload: _getFolderInfo)
: circularLoading
: m3eLoading
: _buildBody(theme),
);
}

View File

@@ -41,7 +41,7 @@ class _FavPanelState extends State<FavPanel> {
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,

View File

@@ -109,7 +109,7 @@ class _FollowPageState extends State<FollowPage> {
Widget _buildBody(LoadingState loadingState) {
return switch (loadingState) {
Loading() => circularLoading,
Loading() => m3eLoading,
Success() => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [

View File

@@ -68,7 +68,7 @@ class _GroupPanelState extends State<GroupPanel> {
Widget get _buildBody {
return switch (loadingState) {
Loading() => circularLoading,
Loading() => m3eLoading,
Success(:final response) => ListView.builder(
controller: widget.scrollController,
itemCount: response.length,

View File

@@ -57,7 +57,7 @@ class _LiveEmotePanelState extends State<LiveEmotePanel>
2,
);
return switch (loadingState) {
Loading() => circularLoading,
Loading() => m3eLoading,
Success(:final response) =>
response != null && response.isNotEmpty
? Column(

View File

@@ -102,7 +102,7 @@ class _LoginPageState extends State<LoginPage> {
Loading() => const SizedBox(
height: 200,
width: 200,
child: circularLoading,
child: m3eLoading,
),
Success(:final response) => Container(
width: 200,

View File

@@ -68,7 +68,7 @@ class _MemberPageState extends State<MemberPage> {
color: theme.surface,
child: Obx(
() => switch (_userController.loadingState.value) {
Loading() => circularLoading,
Loading() => m3eLoading,
Success(:final response) => ExtendedNestedScrollView(
key: _userController.key,
onlyOneScrollInBody: true,

View File

@@ -73,7 +73,7 @@ class _MemberHomeState extends State<MemberHome>
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(

View File

@@ -127,7 +127,7 @@ class _EditProfilePageState extends State<EditProfilePage> {
);
return switch (loadingState) {
Loading() => circularLoading,
Loading() => m3eLoading,
Success(:final response) => ListView(
padding: EdgeInsets.only(
bottom: MediaQuery.viewPaddingOf(context).bottom + 25,

View File

@@ -83,7 +83,7 @@ class _PgcPageState extends State<PgcPage> with AutomaticKeepAliveClientMixin {
ThemeData theme,
LoadingState<List<TimelineResult>?> loadingState,
) => switch (loadingState) {
Loading() => circularLoading,
Loading() => m3eLoading,
Success(:final response) =>
response != null && response.isNotEmpty
? Builder(
@@ -397,7 +397,7 @@ class _PgcPageState extends State<PgcPage> with AutomaticKeepAliveClientMixin {
Widget _buildFollowBody(LoadingState<List<FavPgcItemModel>?> loadingState) {
return switch (loadingState) {
Loading() => circularLoading,
Loading() => m3eLoading,
Success(:final response) =>
response != null && response.isNotEmpty
? ListView.builder(

View File

@@ -58,7 +58,7 @@ class _PgcIndexPageState extends State<PgcIndexPage>
) {
final padding = MediaQuery.viewPaddingOf(context);
return switch (loadingState) {
Loading() => circularLoading,
Loading() => m3eLoading,
Success(:final response) => Builder(
builder: (context) {
int count =

View File

@@ -81,7 +81,7 @@ class _HorizontalMemberPageState extends State<HorizontalMemberPage> {
Widget _buildUserPage(ThemeData theme, LoadingState userState) {
return switch (userState) {
Loading() => circularLoading,
Loading() => m3eLoading,
Success(:final response) => Column(
children: [
_buildUserInfo(theme, response),

View File

@@ -37,7 +37,7 @@ class _WhisperBlockPageState extends State<WhisperBlockPage> {
LoadingState<List<KeywordBlockingItem>?> loadingState,
) {
return switch (loadingState) {
Loading() => circularLoading,
Loading() => m3eLoading,
Success(:final response) =>
response != null && response.isNotEmpty
? Column(

View File

@@ -151,7 +151,7 @@ class _WhisperDetailPageState
Widget _buildBody(LoadingState<List<Msg>?> loadingState) {
return switch (loadingState) {
Loading() => circularLoading,
Loading() => m3eLoading,
Success(:final response) =>
response != null && response.isNotEmpty
? ChatListView.separated(