feat(learn): Explore by what a licence allows, a skeleton while the library loads, and an assistant's agreement per provider (ADR 0005, rule 12; ADR 0021, D66)

- Explore filters by what a studyset's licence lets a learner do: adapt
  it (no NoDerivatives) and use it commercially (no NonCommercial),
  which the instance reads from the release's licence
  (ExploreQuery.adaptable, commercial) and the app the same way
  (LicenseUse). An instance from before ignores both.
- While the studysets are read, the Library shows shimmering skeletons
  of their cards rather than a spinner: halkyon_ui's new Shimmer and
  SkeletonBox, still where the system asks for no animations.
- The scope's "Include private studysets" is offered only while the
  person has a private studyset.
- An agreement to what goes where is per provider (protocol and host):
  agreeing to one assistant agrees to every assistant of that provider,
  those granted later too, and the agreement sheet says so. No
  migration: it is written on every grant of the provider, so taking
  one back keeps it for the others, and read per provider from any of
  them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-02 01:09:04 +01:00
commit 5641285d31
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 970 additions and 85 deletions

View file

@ -33,7 +33,7 @@ LeanCode's lints.
the bar), whichever router drives it;
`AppLoadingIndicator`, `ButtonLoadingIndicator`, `AppRefreshIndicator`;
`SectionCard`, `SliverCentered`, `EmptyListMessage`, `CountBadge`,
`TitleWithKicker`, `CollapsingRow`, `ScrollHidingFab`, `liftWhileDragged`,
`TitleWithKicker`, `CollapsingRow`, `ScrollHidingFab`, `Shimmer` and `SkeletonBox`, `liftWhileDragged`,
`ReorderToggle`, `ReorderGrip`; `ChevronField`, `requiredDecoration`,
`FieldInfoButton`, `CountStepper`, `PaletteColorPicker`, `TextLimits` with
`nearLimitCounter`; `confirmDestructiveAction`, `showUndoSnackBar`,

View file

@ -37,6 +37,7 @@ export 'src/widget/reorder_toggle.dart';
export 'src/widget/screen_tint.dart';
export 'src/widget/scroll_hiding_fab.dart';
export 'src/widget/section_card.dart';
export 'src/widget/skeleton.dart';
export 'src/widget/sliver_centered.dart';
export 'src/widget/tab_visibility.dart';
export 'src/widget/text_limits.dart';

View file

@ -0,0 +1,97 @@
import 'package:material_ui/material_ui.dart';
/// Where content will stand once it has loaded: a block in the theme's
/// muted colour, [width] by [height] (as wide as it may be where null),
/// with corners of [radius]. Inside a [Shimmer], a light band sweeps
/// across it.
class const SkeletonBox({
final double? width,
final double? height,
final double radius = 6,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => SizedBox(
width: width ?? double.infinity,
height: height,
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(radius),
),
),
);
}
/// A light band sweeping across the [SkeletonBox]es in [child] every
/// 1.4 seconds, while a list loads (decided 2026-11-01, as Vaia shows its
/// library loading, over a spinner); still where the system asks for no
/// animations.
class const Shimmer({required final Widget child, super.key})
extends StatefulWidget {
@override
State<Shimmer> createState() => _ShimmerState();
}
class _ShimmerState()
extends State<Shimmer>
with SingleTickerProviderStateMixin {
late final _sweep = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1400),
);
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (MediaQuery.disableAnimationsOf(context)) {
_sweep.stop();
} else if (!_sweep.isAnimating) {
_sweep.repeat();
}
}
@override
void dispose() {
_sweep.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (MediaQuery.disableAnimationsOf(context)) {
return widget.child;
}
final scheme = Theme.of(context).colorScheme;
final base = scheme.surfaceContainerHighest;
final light = Color.lerp(base, scheme.surface, 0.6)!;
return AnimatedBuilder(
animation: _sweep,
child: widget.child,
builder: (context, child) => ShaderMask(
// Painted over the boxes alone, where they are.
blendMode: BlendMode.srcATop,
shaderCallback: (bounds) => LinearGradient(
colors: [base, light, base],
stops: const [0.35, 0.5, 0.65],
transform: _Slide(_sweep.value),
).createShader(bounds),
child: child,
),
);
}
}
/// Moves a gradient from one side of its bounds, at 0, past the other,
/// at 1.
final class const _Slide(final double value) extends GradientTransform {
@override
Matrix4 transform(Rect bounds, {TextDirection? textDirection}) {
final sign = textDirection == TextDirection.rtl ? -1 : 1;
return Matrix4.translationValues(
sign * (value * 2 - 1) * bounds.width,
0,
0,
);
}
}

View file

@ -0,0 +1,46 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
void main() {
Future<void> show(WidgetTester tester, {bool still = false}) =>
tester.pumpWidget(
MaterialApp(
theme: appTheme(Brightness.light),
home: MediaQuery(
data: MediaQueryData(disableAnimations: still),
child: const Scaffold(
body: Shimmer(
child: Column(
children: [
SkeletonBox(width: 120, height: 18),
SkeletonBox(height: 12),
],
),
),
),
),
),
);
testWidgets('a skeleton stands where content will, in the theme’s '
'muted colour, a light band sweeping across it', (tester) async {
await show(tester);
expect(tester.getSize(find.byType(SkeletonBox).first), const Size(120, 18));
expect(find.byType(ShaderMask), findsOneWidget);
expect(tester.hasRunningAnimations, isTrue);
await tester.pump(const Duration(seconds: 1));
expect(tester.hasRunningAnimations, isTrue, reason: 'sweeping on');
});
testWidgets('it stands still where the system asks for no animations', (
tester,
) async {
await show(tester, still: true);
expect(find.byType(SkeletonBox), findsNWidgets(2));
expect(find.byType(ShaderMask), findsNothing);
expect(tester.hasRunningAnimations, isFalse);
});
}