fix(studyset): the parts' switch centred above the material, a section's buttons at its edge
From expanded windows on, the switch between Material, Progress and Study plan stands centred above the column it switches, rather than at the end of the path's line; where that column is narrower than the switch, it ends the line as before. The path takes what is left of the line and folds before it. A section's title was Flexible beside a Spacer, which share the free room between them, so Browse, Reorder and Show all ended halfway across a wide section. The title now takes what the buttons leave. The study plan's paths and Explore's licence row had the same row. 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
38a21b2a97
commit
51ddd5a5e6
5 changed files with 163 additions and 39 deletions
|
|
@ -463,8 +463,14 @@ class const _ExploreCard({
|
|||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Flexible(child: LicenseChip(license: studyset.license)),
|
||||
const Spacer(),
|
||||
// The chip takes what the button leaves, so the button
|
||||
// ends at the edge.
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: LicenseChip(license: studyset.license),
|
||||
),
|
||||
),
|
||||
ExploreAddButton(studyset: studyset, state: state),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ class const StudysetBreadcrumb({
|
|||
/// How high every step is, to tap.
|
||||
static const minTarget = 48.0;
|
||||
|
||||
/// How far before the screen's end the line ends when [trailing] ends it.
|
||||
static const trailingEndPadding = 8.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
|
@ -141,7 +144,12 @@ class const StudysetBreadcrumb({
|
|||
},
|
||||
);
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 0, trailing == null ? 16 : 8, 0),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
0,
|
||||
trailing == null ? 16 : trailingEndPadding,
|
||||
0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: path),
|
||||
|
|
|
|||
|
|
@ -347,18 +347,26 @@ class _MaterialPartState() extends State<MaterialPart> {
|
|||
padding: const EdgeInsets.fromLTRB(16, 16, 8, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
// The title takes what the buttons leave, so they end at the
|
||||
// section's edge; a Spacer beside a Flexible title would share
|
||||
// that room with it and leave them halfway.
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (count != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
_CountBadge(count: count),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (count != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
_CountBadge(count: count),
|
||||
],
|
||||
const Spacer(),
|
||||
...actions,
|
||||
if (reorderable != null &&
|
||||
((count ?? 0) >= 2 || reorderable.reordering))
|
||||
|
|
@ -875,16 +883,21 @@ class _PlanPartState() extends State<PlanPart> {
|
|||
padding: const EdgeInsets.fromLTRB(16, 16, 8, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
l10n.planLearningPaths,
|
||||
style: theme.textTheme.titleMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
l10n.planLearningPaths,
|
||||
style: theme.textTheme.titleMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CountBadge(count: total),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CountBadge(count: total),
|
||||
const Spacer(),
|
||||
if (total >= 2 || _reordering)
|
||||
ReorderToggle(
|
||||
reordering: _reordering,
|
||||
|
|
|
|||
|
|
@ -340,7 +340,7 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
content,
|
||||
parts,
|
||||
description,
|
||||
roomy: constraints.maxWidth >= _roomyWidth,
|
||||
width: constraints.maxWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -350,6 +350,11 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
/// How wide each part's button is in the path's line.
|
||||
static const _partWidth = 120.0;
|
||||
|
||||
/// From expanded windows on: how wide the column of the description and
|
||||
/// the topics is, and the room around and between the two columns.
|
||||
static const _sideColumnWidth = 420.0;
|
||||
static const _gutter = 12.0;
|
||||
|
||||
/// How wide the screen has to be for Add material to show its label in
|
||||
/// the app bar; below, it is an icon, so Study, Share and the options fit.
|
||||
static const _roomyWidth = 960.0;
|
||||
|
|
@ -361,8 +366,9 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
Widget content,
|
||||
_PartsView parts,
|
||||
Widget? description, {
|
||||
required bool roomy,
|
||||
required double width,
|
||||
}) {
|
||||
final roomy = width >= _roomyWidth;
|
||||
final studyset = widget.studyset;
|
||||
final all = widget.all;
|
||||
final theme = Theme.of(context);
|
||||
|
|
@ -373,8 +379,9 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
context.select<PdfListBloc, bool>((bloc) => bloc.state.importing) ||
|
||||
context.select<PathListBloc, bool>((bloc) => bloc.state.importing);
|
||||
// In the header, as Vaia has it (decided 2026-10-12): below expanded
|
||||
// windows in a line of its own under the title, from there on at the end
|
||||
// of the path's line, which the topics leave free there.
|
||||
// windows in a line of its own under the title, from there on in the
|
||||
// path's line, which the topics leave free there, centred above the
|
||||
// material it switches.
|
||||
final partSwitch = parts.settled
|
||||
? StudysetPartSwitch(
|
||||
parts: parts.parts,
|
||||
|
|
@ -476,9 +483,10 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
enabled: !busy,
|
||||
trailing: wide
|
||||
? switch (partSwitch) {
|
||||
final partSwitch? => SizedBox(
|
||||
width: _partWidth * parts.parts.length,
|
||||
child: partSwitch,
|
||||
final partSwitch? => _aboveMaterial(
|
||||
partSwitch,
|
||||
switchWidth: _partWidth * parts.parts.length,
|
||||
width: width,
|
||||
),
|
||||
null => null,
|
||||
}
|
||||
|
|
@ -502,12 +510,17 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
Expanded(
|
||||
child: wide
|
||||
? Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
_gutter,
|
||||
4,
|
||||
_gutter,
|
||||
_gutter,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 420,
|
||||
width: _sideColumnWidth,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
|
|
@ -524,7 +537,7 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const SizedBox(width: _gutter),
|
||||
Expanded(
|
||||
child: SectionCard(
|
||||
padding: EdgeInsets.zero,
|
||||
|
|
@ -549,6 +562,30 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
);
|
||||
}
|
||||
|
||||
/// [partSwitch], [switchWidth] wide, at the end of the path's line where
|
||||
/// it stands centred above the material's column, in a screen [width]
|
||||
/// wide; at the line's end where that column is narrower than the switch.
|
||||
static Widget _aboveMaterial(
|
||||
Widget partSwitch, {
|
||||
required double switchWidth,
|
||||
required double width,
|
||||
}) {
|
||||
const columnStart = _gutter + _sideColumnWidth + _gutter;
|
||||
final columnEnd = width - _gutter;
|
||||
final switchEnd = (columnStart + columnEnd + switchWidth) / 2;
|
||||
return Padding(
|
||||
// The line ends [StudysetBreadcrumb.trailingEndPadding] before the
|
||||
// screen does.
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
end: math.max(
|
||||
0,
|
||||
width - StudysetBreadcrumb.trailingEndPadding - switchEnd,
|
||||
),
|
||||
),
|
||||
child: SizedBox(width: switchWidth, child: partSwitch),
|
||||
);
|
||||
}
|
||||
|
||||
/// How much of the window's height the topics' sheet takes.
|
||||
static const _sheetHeight = 0.7;
|
||||
|
||||
|
|
|
|||
|
|
@ -12,9 +12,11 @@ import 'package:halkyon_learn_flutter/src/core/theme/palette_color.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/empty_list_message.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/palette_color_picker.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_list.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
|
||||
|
|
@ -141,8 +143,12 @@ void main() {
|
|||
|
||||
/// Opens the studyset titled [title] from the library, so its screen has
|
||||
/// the library below it.
|
||||
Future<void> openFromLibrary(WidgetTester tester, String title) async {
|
||||
await showApp(tester);
|
||||
Future<void> openFromLibrary(
|
||||
WidgetTester tester,
|
||||
String title, {
|
||||
double width = 1200,
|
||||
}) async {
|
||||
await showApp(tester, width: width);
|
||||
await tester.tap(find.text(title));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
|
@ -1390,8 +1396,8 @@ void main() {
|
|||
|
||||
expect(targetOf('…'), const Size.square(StudysetBreadcrumb.minTarget));
|
||||
|
||||
// Wide enough for the whole path.
|
||||
await openFromLibrary(tester, 'Japanese');
|
||||
// Wide enough for the whole path beside the parts' switch.
|
||||
await openFromLibrary(tester, 'Japanese', width: 1600);
|
||||
expect(targetOf('Library').height, StudysetBreadcrumb.minTarget);
|
||||
});
|
||||
|
||||
|
|
@ -1600,9 +1606,8 @@ void main() {
|
|||
];
|
||||
|
||||
testWidgets('the parts are switched in the header: on a phone under the '
|
||||
"title, above the path; on a desktop at the end of the path's line", (
|
||||
tester,
|
||||
) async {
|
||||
"title, above the path; on a desktop in the path's line, centred above "
|
||||
'the material, where it fits', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
await showApp(
|
||||
tester,
|
||||
|
|
@ -1626,7 +1631,62 @@ void main() {
|
|||
);
|
||||
// The path's line stays as high as its steps.
|
||||
expect(pathRect().height, StudysetBreadcrumb.minTarget);
|
||||
Rect materialRect() => tester.getRect(
|
||||
find
|
||||
.ancestor(
|
||||
of: find.byType(MaterialPart),
|
||||
matching: find.byType(SectionCard),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
expect(
|
||||
switchRect().center.dx,
|
||||
moreOrLessEquals(materialRect().center.dx, epsilon: 1),
|
||||
);
|
||||
|
||||
// Where the material is narrower than the switch, all three parts
|
||||
// once a card was studied, it ends the line.
|
||||
final id = await card('japanese', '猫');
|
||||
await PersonScope.of(
|
||||
tester.element(find.byType(StudysetScreen)),
|
||||
).reviews.record(
|
||||
cardId: id,
|
||||
rating: Rating.good,
|
||||
reviewedAt: clock.now(),
|
||||
duration: const Duration(minutes: 2),
|
||||
);
|
||||
await showApp(
|
||||
tester,
|
||||
location: RoutePaths.studyset('japanese'),
|
||||
width: 860,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(parts(tester), hasLength(3));
|
||||
expect(materialRect().width, lessThan(switchRect().width));
|
||||
expect(switchRect().right, greaterThan(pathRect().right - 16));
|
||||
expect(switchRect().right, lessThanOrEqualTo(pathRect().right));
|
||||
});
|
||||
|
||||
testWidgets("a section's buttons end at the material's edge, however "
|
||||
'short its title', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
await card('japanese', '猫');
|
||||
await showApp(tester, location: RoutePaths.studyset('japanese'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final material = tester.getRect(
|
||||
find
|
||||
.ancestor(
|
||||
of: find.byType(MaterialPart),
|
||||
matching: find.byType(SectionCard),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
// Browse, then the filter and the menu, the last at the edge.
|
||||
expect(
|
||||
tester.getRect(find.byType(CardListToolbar)).right,
|
||||
greaterThan(material.right - 16),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('Progress shows once a card in the studyset was studied: how '
|
||||
|
|
|
|||
Loading…
Reference in a new issue