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:
Tizian 「ティツィアーン」 2026-10-12 22:32:59 +02:00
commit a57816a869
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
10 changed files with 194 additions and 25 deletions

View file

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

View file

@ -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,
),
),
),
),
),
),
),
],
);
}
}

View file

@ -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())),
);
}

View file

@ -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),
),
],

View file

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

View file

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

View file

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

View file

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

View file

@ -148,7 +148,7 @@ Widget _pullToSync(
BuildContext context,
ScrollController scroll,
List<Widget> slivers,
) => RefreshIndicator(
) => AppRefreshIndicator(
onRefresh: () => syncAndWait(context.read<SyncStatusBloc>()),
child: CustomScrollView(
controller: scroll,

View file

@ -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);
});
}