Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/DESIGN_SYSTEM.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Typography uses the existing Flutter font stack for body text, plus a bundled `M
- Reduced motion shows the final matrix, all three explanations, and all calculations; completion still waits for the reading interval. Backgrounding the app pauses playback.
- Cache discrete matrix-cell content per step and theme; rebuild the animation scene without rebuilding static math cells every frame. Keep caches bounded to the current step.
- Keypad and dimension controls have at least 44 px targets. Standard action buttons have a 48 px minimum. This is not a claim of comprehensive WCAG certification.
- Math text follows system text scaling. Wide values use horizontal scrolling rather than shrinking below the chosen type size. Repeated right-side row annotations are omitted; the operation formula remains above the matrix. Normal matrix cells are quiet, with role borders only where instructional meaning requires them. Player values adapt between 18 and 22 px using available layout width. During an operation, exact cell formulas fit down to a 14 px minimum (before system text scaling); results return to the normal size. Very long expressions remain scrollable. Guided operation cells reserve up to 120 px width, capped at the width actually available per column so the reserve never crowds a value off a narrow screen; wide matrices follow the active column, and a step that is not animating (static steps, a paused or finished lesson) opens scrolled to the cells it highlights, centred on the pivot or badged entry when the whole row cannot fit. Cell badges ("0 ✓", "≠0") sit on the cell's top-right corner, over its border, so they never cover a wide value. A TeX matrix or vector that holds a fraction (an LU factor, a solution vector, an eigenvector) sets its entries in display style with a strut per row (`displayStyleMatrixCells`), because the renderer otherwise shrinks them to script size. Full calculation panels wrap at TeX operator boundaries.
- Math text follows system text scaling. Wide values use horizontal scrolling rather than shrinking below the chosen type size. Repeated right-side row annotations are omitted; the operation formula remains above the matrix. Normal matrix cells are quiet, with role borders only where instructional meaning requires them. Player values adapt between 18 and 22 px using available layout width. During an operation, exact cell formulas fit down to a 14 px minimum (before system text scaling); results return to the normal size. Very long expressions remain scrollable. On the result view, a formula wider than the screen (the L and U of a 5×5 LU on a phone, or its check) shows its horizontal scrollbar (`SidewaysFormula`), because Flutter draws none for a horizontal scroll view and a matrix cut at the edge otherwise looks complete; room for the thumb is reserved only while the formula overflows. Guided operation cells reserve up to 120 px width, capped at the width actually available per column so the reserve never crowds a value off a narrow screen; wide matrices follow the active column, and a step that is not animating (static steps, a paused or finished lesson) opens scrolled to the cells it highlights, centred on the pivot or badged entry when the whole row cannot fit. Cell badges ("0 ✓", "≠0") sit on the cell's top-right corner, over its border, so they never cover a wide value. A TeX matrix or vector that holds a fraction (an LU factor, a solution vector, an eigenvector) sets its entries in display style with a strut per row (`displayStyleMatrixCells`), because the renderer otherwise shrinks them to script size. Full calculation panels wrap at TeX operator boundaries.
- Below 600 px, use a single column. From 600–959 px, use flexible or scrolling layouts; the transform canvas can split in short landscape. The step player is one centred stage column (max width 880) at every screen width, never side-by-side; wide screens only get more padding around it. The matrix editor (`matrix_input`) and the transform visualizer still switch to side-by-side layouts from 960 px when text scale permits; the matrix editor also does on short landscape screens (under 500 px high, at least 600 px wide). Large text falls back to scrolling.
- Transform presets show selection; editing a coefficient sets Custom. The 2×2 coefficient grid supports direct entry of integers, decimals and fractions, and steppers; values are held exactly (the 45° rotation shows √2/2). Entries commit on Enter/blur; invalid drafts retain the previous valid transformation. The canvas transitions from its visible frame to the new target. Rotations preserve lengths when both endpoints are rotations; other intermediate frames are explicitly described as transitions. Basis vectors and target determinant are separately labeled. Basis vectors and the target determinant always show the target's exact values, including during animation; nothing is rounded.
- Show A’s source row and B’s source column above C during multiplication. Box each corresponding factor pair in sequence. Delay C’s result until all contributions are complete; avoid small canvas-drawn arithmetic labels.
Expand Down
61 changes: 55 additions & 6 deletions lib/features/step_player/widgets/solution_summary.dart
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,7 @@ class SolutionSummary extends StatelessWidget {
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (final piece in _splitAtImplies(part))
SingleChildScrollView(
scrollDirection: Axis.horizontal,
SidewaysFormula(
child: MathText(piece, fontSize: 24),
),
],
Expand Down Expand Up @@ -204,10 +203,7 @@ class ResultChecks extends StatelessWidget {
Text(check.description, style: theme.textTheme.bodyMedium),
const SizedBox(height: 6),
],
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: MathText(check.latex, fontSize: 18),
),
SidewaysFormula(child: MathText(check.latex, fontSize: 18)),
const SizedBox(height: 6),
Row(
children: [
Expand Down Expand Up @@ -235,6 +231,59 @@ class ResultChecks extends StatelessWidget {
}
}

/// A formula that scrolls sideways when it is wider than the screen, with a
/// visible scrollbar.
///
/// Flutter adds no scrollbar to a horizontal scroll view by itself, and a
/// matrix cut at the edge (the L of a 5×5 LU on a phone) otherwise looks
/// complete. Room for the thumb is reserved only while the formula overflows,
/// so a formula that fits keeps its spacing.
class SidewaysFormula extends StatefulWidget {
final Widget child;
const SidewaysFormula({super.key, required this.child});

@override
State<SidewaysFormula> createState() => _SidewaysFormulaState();
}

class _SidewaysFormulaState extends State<SidewaysFormula> {
final _controller = ScrollController();
var _overflows = false;

@override
void dispose() {
_controller.dispose();
super.dispose();
}

bool _onMetrics(ScrollMetricsNotification notification) {
if (notification.depth == 0) {
final overflows =
notification.metrics.maxScrollExtent >
notification.metrics.minScrollExtent;
if (overflows != _overflows) setState(() => _overflows = overflows);
}
return false;
}

@override
Widget build(BuildContext context) {
return NotificationListener<ScrollMetricsNotification>(
onNotification: _onMetrics,
child: Scrollbar(
controller: _controller,
thumbVisibility: true,
child: SingleChildScrollView(
controller: _controller,
scrollDirection: Axis.horizontal,
padding: EdgeInsets.only(bottom: _overflows ? 10 : 0),
child: widget.child,
),
),
);
}
}

/// `a \implies b` as `a \implies` and `b`; anything else unchanged.
List<String> _splitAtImplies(String latex) {
const arrow = r' \implies ';
Expand Down
137 changes: 137 additions & 0 deletions test/result_scrollbar_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:matrix_engine/matrix_engine.dart';
import 'package:matriks/core/widgets/math_text.dart';
import 'package:matriks/features/step_player/widgets/solution_summary.dart';

import 'product_accessibility_test.dart' show host;

void main() {
// L and U of this matrix hold fractions such as -1509/652: far wider than
// a phone.
final wide = LUDecompositionSolver.solve(
Matrix.fromInts([
[7, 3, 5, 2, 9],
[3, 8, 1, 6, 4],
[5, 2, 9, 3, 7],
[2, 6, 4, 8, 1],
[9, 4, 7, 1, 6],
]),
);
final small = LUDecompositionSolver.solve(
Matrix.fromInts([
[2, 1],
[4, 3],
]),
);

Future<void> show(WidgetTester tester, StepSolution solution, Size size) {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
return tester.pumpWidget(
host(
Scaffold(
body: SolutionSummary(
solution: solution,
decimal: false,
onViewSteps: () {},
),
),
),
);
}

Finder formula(String start) => find.byWidgetPredicate(
(widget) => widget is MathText && widget.latex.startsWith(start),
);

ScrollPosition positionOf(WidgetTester tester, Finder formula) => tester
.state<ScrollableState>(
find.ancestor(of: formula, matching: find.byType(Scrollable)).first,
)
.position;

testWidgets('A result wider than a phone shows a scrollbar that scrolls it', (
tester,
) async {
await show(tester, wide, const Size(390, 844));
await tester.pumpAndSettle();
final l = formula('L =');
expect(l, findsOneWidget);
final position = positionOf(tester, l);
expect(position.maxScrollExtent, greaterThan(0));
expect(position.pixels, 0);

// The thumb sits along the bottom edge of the formula's viewport; a
// mouse moves the formula only by dragging it.
final viewport = tester.getRect(
find.ancestor(of: l, matching: find.byType(Scrollable)).first,
);
await tester.dragFrom(
Offset(viewport.left + 12, viewport.bottom - 4),
const Offset(120, 0),
kind: PointerDeviceKind.mouse,
);
await tester.pumpAndSettle();
expect(position.pixels, greaterThan(0));
}, variant: TargetPlatformVariant.only(TargetPlatform.windows));

testWidgets('The check under the result scrolls the same way', (
tester,
) async {
// L·U = A repeats A, so only large entries make the check itself wide.
final large = LUDecompositionSolver.solve(
Matrix.fromInts([
[-4817, 2093, -7561, 3348, -1229],
[6402, -3915, 1187, -8836, 5071],
[-2254, 7718, -4409, 1963, -6632],
[8145, -1376, 5920, -2781, 3304],
[-3593, 6047, -8218, 4456, -1985],
]),
);
await show(tester, large, const Size(390, 844));
await tester.pumpAndSettle();
final check = formula('L U =');
expect(check, findsOneWidget);
final position = positionOf(tester, check);
expect(position.maxScrollExtent, greaterThan(0));
final bar = tester.widget<Scrollbar>(
find.ancestor(of: check, matching: find.byType(Scrollbar)).first,
);
expect(bar.thumbVisibility, isTrue);
expect(bar.controller!.position, same(position));
});

testWidgets('A result that fits keeps its spacing and shows no thumb', (
tester,
) async {
await show(tester, small, const Size(1280, 900));
await tester.pumpAndSettle();
final l = formula('L =');
expect(positionOf(tester, l).maxScrollExtent, 0);
final view = tester.widget<SingleChildScrollView>(
find.ancestor(of: l, matching: find.byType(SingleChildScrollView)).first,
);
expect(view.padding, EdgeInsets.zero);
});

testWidgets('Room for the thumb is made only while a formula overflows', (
tester,
) async {
await show(tester, wide, const Size(390, 844));
await tester.pumpAndSettle();
final l = formula('L =');
SingleChildScrollView view() => tester.widget<SingleChildScrollView>(
find.ancestor(of: l, matching: find.byType(SingleChildScrollView)).first,
);
expect(view().padding, const EdgeInsets.only(bottom: 10));

// On a window wide enough for it the room goes again.
tester.view.physicalSize = const Size(1280, 900);
await tester.pumpAndSettle();
expect(positionOf(tester, l).maxScrollExtent, 0);
expect(view().padding, EdgeInsets.zero);
});
}
Loading