fix(theme): every spinner is Material 3 Expressive's, pulling down too
The app's spinner is Material 3 Expressive's morphing indicator, but pulling a list down (the library, a studyset's parts, Explore) showed Flutter's older circular one, as did the spinners in buttons and a few dialogs. Pulling down now brings the app's spinner in a small round container that comes down with the pull and stays while it refreshes (AppRefreshIndicator, over Flutter's RefreshIndicator without its own spinner), and buttons show it small in their own colour (ButtonLoadingIndicator). Only determinate progress (a mastery ring) stays a circle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
5455453d20
commit
a57816a869
10 changed files with 194 additions and 25 deletions
|
|
@ -15,6 +15,7 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/editor/atomic_block.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
|
|
@ -272,10 +273,7 @@ class const _SoundButton() extends StatelessWidget {
|
|||
onPressed: () =>
|
||||
context.read<SoundBloc>().add(const SoundEventToggled()),
|
||||
icon: switch (state) {
|
||||
SoundState.loading => const SizedBox.square(
|
||||
dimension: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
SoundState.loading => const ButtonLoadingIndicator(),
|
||||
SoundState.playing => const Icon(LucideIcons.square),
|
||||
SoundState.idle ||
|
||||
SoundState.missing ||
|
||||
|
|
|
|||
|
|
@ -3,19 +3,152 @@ import 'package:material_ui/material_ui.dart';
|
|||
|
||||
/// The app's spinner for anything still loading: Material 3 Expressive's
|
||||
/// morphing indicator, which Flutter doesn't ship yet. Every loading state
|
||||
/// shows this widget, so swapping in the SDK's own indicator one day changes
|
||||
/// one file. Determinate progress (a mastery ring) and small in-button
|
||||
/// spinners stay [CircularProgressIndicator]s.
|
||||
/// shows this widget, a button's too ([ButtonLoadingIndicator]) and pulling
|
||||
/// a list down ([AppRefreshIndicator]), so swapping in the SDK's own
|
||||
/// indicator one day changes one file. Determinate progress (a mastery
|
||||
/// ring) stays a [CircularProgressIndicator].
|
||||
class const AppLoadingIndicator({
|
||||
/// The side of its square; Material's 48 dp when null.
|
||||
final double? size,
|
||||
|
||||
/// Its colour; the theme's primary colour when null.
|
||||
final Color? color,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => ExpressiveLoadingIndicator(
|
||||
color: color,
|
||||
constraints: switch (size) {
|
||||
final size? => BoxConstraints.tightFor(width: size, height: size),
|
||||
null => null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// The app's spinner in a button, in place of its icon or label while what
|
||||
/// it started is under way: small, in the button's own colour.
|
||||
class const ButtonLoadingIndicator({super.key}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => AppLoadingIndicator(
|
||||
size: 20,
|
||||
color:
|
||||
IconTheme.of(context).color ?? DefaultTextStyle.of(context).style.color,
|
||||
);
|
||||
}
|
||||
|
||||
/// Pulling [child] down runs [onRefresh], the app's spinner showing
|
||||
/// meanwhile, as Material 3 Expressive's pull to refresh has it: in a small
|
||||
/// round container that comes down with the pull and stays while it
|
||||
/// refreshes. Flutter's own [RefreshIndicator] draws the older spinner, so
|
||||
/// it only does the pulling here (its `noSpinner` kind).
|
||||
class const AppRefreshIndicator({
|
||||
required final RefreshCallback onRefresh,
|
||||
required final Widget child,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
/// How far down the container comes at most, and stays while refreshing.
|
||||
static const restingTop = 24.0;
|
||||
|
||||
/// The container's side.
|
||||
static const containerSize = 44.0;
|
||||
|
||||
@override
|
||||
State<AppRefreshIndicator> createState() => _AppRefreshIndicatorState();
|
||||
}
|
||||
|
||||
class _AppRefreshIndicatorState() extends State<AppRefreshIndicator> {
|
||||
RefreshIndicatorStatus? _status;
|
||||
|
||||
/// How far the list was pulled past its top, in logical pixels.
|
||||
var _pulled = 0.0;
|
||||
|
||||
bool _tracked(ScrollNotification notification) {
|
||||
if (notification.depth != 0 || notification.metrics.axis != Axis.vertical) {
|
||||
return false;
|
||||
}
|
||||
switch (notification) {
|
||||
case ScrollStartNotification():
|
||||
if (_status != RefreshIndicatorStatus.refresh) {
|
||||
_pulled = 0;
|
||||
}
|
||||
case OverscrollNotification(:final overscroll)
|
||||
when overscroll < 0 && notification.metrics.extentBefore == 0:
|
||||
setState(() => _pulled -= overscroll);
|
||||
case ScrollUpdateNotification(:final scrollDelta?)
|
||||
when _pulled > 0 && scrollDelta > 0:
|
||||
setState(() => _pulled = (_pulled - scrollDelta).clamp(0, _pulled));
|
||||
// A list that bounces (iOS) goes past its top by itself.
|
||||
case ScrollUpdateNotification(:final metrics)
|
||||
when metrics.pixels < metrics.minScrollExtent:
|
||||
setState(() => _pulled = metrics.minScrollExtent - metrics.pixels);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final status = _status;
|
||||
final refreshing =
|
||||
status == RefreshIndicatorStatus.refresh ||
|
||||
status == RefreshIndicatorStatus.snap;
|
||||
final pulling =
|
||||
status == RefreshIndicatorStatus.drag ||
|
||||
status == RefreshIndicatorStatus.armed;
|
||||
const size = AppRefreshIndicator.containerSize;
|
||||
// Comes down with the pull, half as fast as the finger, to rest.
|
||||
final top = refreshing
|
||||
? AppRefreshIndicator.restingTop
|
||||
: (_pulled / 2 - size).clamp(-size, AppRefreshIndicator.restingTop);
|
||||
final shown = refreshing || (pulling && _pulled > 0);
|
||||
return Stack(
|
||||
children: [
|
||||
NotificationListener<ScrollNotification>(
|
||||
onNotification: _tracked,
|
||||
child: RefreshIndicator.noSpinner(
|
||||
onRefresh: widget.onRefresh,
|
||||
onStatusChange: (status) => setState(() {
|
||||
_status = status;
|
||||
if (status == RefreshIndicatorStatus.done ||
|
||||
status == RefreshIndicatorStatus.canceled) {
|
||||
_pulled = 0;
|
||||
}
|
||||
}),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: top,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: IgnorePointer(
|
||||
child: AnimatedOpacity(
|
||||
opacity: shown ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: Center(
|
||||
child: Material(
|
||||
color: colors.primaryContainer,
|
||||
shape: const CircleBorder(),
|
||||
elevation: 3,
|
||||
child: SizedBox.square(
|
||||
dimension: size,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: shown
|
||||
? AppLoadingIndicator(
|
||||
color: colors.onPrimaryContainer,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -376,7 +376,7 @@ class const _Results({required final ExploreState state})
|
|||
child: Text(l10n.exploreClearFilters),
|
||||
),
|
||||
),
|
||||
_ => RefreshIndicator(
|
||||
_ => AppRefreshIndicator(
|
||||
onRefresh: () async => send(ExploreEventFiltered(state.filter)),
|
||||
child: ListView(
|
||||
// Room at the end for the navigation bar floating over the foot.
|
||||
|
|
@ -570,10 +570,7 @@ class const ExploreAddButton({
|
|||
style: style,
|
||||
onPressed: null,
|
||||
child: progress == null
|
||||
? const SizedBox.square(
|
||||
dimension: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
? const ButtonLoadingIndicator()
|
||||
: Text(l10n.explorePercent((progress * 100).round())),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/instance/instance.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/instance/instance_checker.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
|
|
@ -124,10 +125,7 @@ class _InstanceStepState() extends State<InstanceStep> {
|
|||
FilledButton(
|
||||
onPressed: _checking ? null : _continue,
|
||||
child: _checking
|
||||
? const SizedBox.square(
|
||||
dimension: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
? const ButtonLoadingIndicator()
|
||||
: Text(l10n.instanceContinue),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart'
|
|||
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_engine.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -202,7 +203,7 @@ class _PdfCaptureOverlayState() extends State<PdfCaptureOverlay> {
|
|||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_working)
|
||||
const CircularProgressIndicator()
|
||||
const AppLoadingIndicator()
|
||||
else
|
||||
Text(
|
||||
l10n.readerCaptureHint,
|
||||
|
|
|
|||
|
|
@ -153,10 +153,7 @@ class _PublishSheetState() extends State<_PublishSheet> {
|
|||
child: FilledButton.icon(
|
||||
onPressed: publishing ? null : _publish,
|
||||
icon: publishing
|
||||
? const SizedBox.square(
|
||||
dimension: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
? const ButtonLoadingIndicator()
|
||||
: const Icon(LucideIcons.globe),
|
||||
label: Text(
|
||||
l10n.publishButton(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/editor/atomic_block.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_focus.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
|
|
@ -277,7 +278,7 @@ class _StudyButtonDialogState() extends State<_StudyButtonDialog> {
|
|||
content: SizedBox(
|
||||
width: 420,
|
||||
child: studysets == null || tags == null
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
? const Center(child: AppLoadingIndicator())
|
||||
: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
|
|
|||
|
|
@ -423,7 +423,7 @@ class _GridState() extends State<_Grid> {
|
|||
final archiveOpen = searching || _archiveOpen;
|
||||
final deletedOpen = searching || _deletedOpen;
|
||||
// Pulled down, the library syncs.
|
||||
return RefreshIndicator(
|
||||
return AppRefreshIndicator(
|
||||
onRefresh: () => Future.wait([
|
||||
syncAndWait(context.read<SyncStatusBloc>()),
|
||||
context.read<PublishedCubit>().refresh(),
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ Widget _pullToSync(
|
|||
BuildContext context,
|
||||
ScrollController scroll,
|
||||
List<Widget> slivers,
|
||||
) => RefreshIndicator(
|
||||
) => AppRefreshIndicator(
|
||||
onRefresh: () => syncAndWait(context.read<SyncStatusBloc>()),
|
||||
child: CustomScrollView(
|
||||
controller: scroll,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:expressive_loading_indicator/expressive_loading_indicator.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/theme/app_theme.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets("pulled down, it refreshes with the app's spinner, Material 3 "
|
||||
"Expressive's, not Flutter's older one", (tester) async {
|
||||
final refreshing = Completer<void>();
|
||||
var refreshes = 0;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: appTheme(Brightness.light),
|
||||
home: Scaffold(
|
||||
body: AppRefreshIndicator(
|
||||
onRefresh: () {
|
||||
refreshes++;
|
||||
return refreshing.future;
|
||||
},
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [Text('Japanese')],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.byType(ExpressiveLoadingIndicator), findsNothing);
|
||||
|
||||
await tester.fling(find.text('Japanese'), const Offset(0, 400), 1000);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(refreshes, 1);
|
||||
expect(find.byType(ExpressiveLoadingIndicator), findsOneWidget);
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
|
||||
refreshing.complete();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(ExpressiveLoadingIndicator), findsNothing);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue