mirror of
https://github.com/bggRGjQaUbCoE/PiliPlus.git
synced 2026-04-20 03:06:59 +08:00
m3e loading indicator
Signed-off-by: dom <githubaccount56556@proton.me>
This commit is contained in:
@@ -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(
|
||||
|
||||
244
lib/common/widgets/loading_widget/m3e_loading_indicator.dart
Normal file
244
lib/common/widgets/loading_widget/m3e_loading_indicator.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -107,7 +107,7 @@ class _CreateFavPageState extends State<CreateFavPage> {
|
||||
? _buildBody(theme)
|
||||
: _errMsg?.isNotEmpty == true
|
||||
? scrollErrorWidget(errMsg: _errMsg, onReload: _getFolderInfo)
|
||||
: circularLoading
|
||||
: m3eLoading
|
||||
: _buildBody(theme),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user