fix(pdf): the divider beside a PDF follows the pointer, and keeps its place
Dragging it moved the sidebar by each event's step from the width last built, so whatever arrived between two frames was lost: the sidebar trailed the pointer, which soon left the 10 dp strip and dropped the drag. Each move also built every editor of the sidebar anew, its blocks being a new list on every build. Now the width follows from where the drag started, the blocks are kept while they stay the same, the handle is 16 dp with a grip under the pointer, and the resize cursor holds over the pages while dragging. The sidebar's share of the room is kept as a device setting once a drag ends, so every PDF opens with it, in any window. 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
f9f81eb800
commit
72cbec52c2
7 changed files with 228 additions and 36 deletions
|
|
@ -83,7 +83,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
|||
- **Manual dependency injection.** One `Dependencies` object (`lib/src/core/dependencies/`) is the composition root, built at startup (`Dependencies.create()`) and exposed through the `DependenciesScope` InheritedWidget. It hands features repositories only. No service locator, no DI framework, no globals (the scaffold's global `client` moved into it). Why: every dependency is a typed, greppable field, and nothing is resolved by magic.
|
||||
- **One graph per context.** The dependency graph belongs to one instance and one signed-in person; when either changes, a `KeyedSubtree` disposes the whole subtree and rebuilds it. Why: nothing from one person's session (blocs, streams, caches) can leak into another's.
|
||||
- **App-wide vs. per person.** `Dependencies` holds what outlives a person (the client, auth). Everything of the signed-in person (their local database, its sync, their repositories) is `PersonDependencies`, opened by `PersonScope` inside `SessionGate`'s keyed subtree (`Dependencies.openPerson`) and disposed with it; widgets reach it through `PersonScope.of(context)`.
|
||||
- **The device's own settings** (appearance, language, citation style; `core/device_settings/`) belong to the device, not to a person: kept in `<app support>/device_settings.json`, read in `Dependencies.create()` so the first frame already has them, and provided by `DeviceSettingsScope` above `SessionGate`'s keyed subtree, which both its plain app and `PersonApp` read. What should follow the person to their other devices goes into their synced `PersonSettings` instead.
|
||||
- **The device's own settings** (appearance, language, citation style, the PDF sidebar's share of the room as last dragged; `core/device_settings/`) belong to the device, not to a person: kept in `<app support>/device_settings.json`, read in `Dependencies.create()` so the first frame already has them, and provided by `DeviceSettingsScope` above `SessionGate`'s keyed subtree, which both its plain app and `PersonApp` read. What should follow the person to their other devices goes into their synced `PersonSettings` instead.
|
||||
- **Every person has an app of their own** (`PersonApp`, `lib/src/app/`), which `SessionGate` builds inside the keyed subtree: a `MaterialApp.router` with its own `GoRouter`, and `PersonScope` around the router's navigator (`MaterialApp.builder`). So every screen and dialog is inside the person's scope and reads their repositories from `PersonScope.of(context)` wherever it is built, and a change of person disposes the router with every screen on it and starts again at home. Until someone is signed in, `SessionGate` shows a plain `MaterialApp`: the spinner, or the failure with a way to try again.
|
||||
- **Platform plugins sit behind small interfaces in `Dependencies`**, with fakes in tests: picking files (`FilePicker`, `file_selector`), saving files the app made, such as exports (`FileSaver`: `file_selector`'s dialog on the desktop, `file_picker`'s on Android and iOS, where `file_selector` has none), playing sounds (`SoundPlayer`, `audioplayers`), playing YouTube videos (`YoutubeVideos`, `youtube_player_iframe` where a web view exists, else the browser), reading PDFs (`PdfEngine`, pdfrx on PDFium, which `flutter test` can't load: widget tests use `FakePdfEngine`, whose PDF bytes say their pages and chapters), reading Anki packages on another isolate (`AnkiPackageReader`; a widget test's fake clock can't wait for one), zipping an export with its files on another isolate (`ExportArchiver`, for the same reason), and the Pomodoro timer's side of the system (`PomodoroPlatform`, `FakePomodoroPlatform` in tests).
|
||||
- **Material's `Card` widget clashes with the domain's `Card`**: files that need both import `material_ui` with `hide Card`.
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ class DeviceSettingsBloc(
|
|||
state.withSearchEngine(event.name, customUrl: event.customUrl),
|
||||
),
|
||||
);
|
||||
on<DeviceSettingsEventPdfSidebarDragged>(
|
||||
(event, emit) => _apply(emit, state.withPdfSidebarShare(event.share)),
|
||||
);
|
||||
on<DeviceSettingsEventBrowserViewChosen>(
|
||||
(event, emit) => _apply(
|
||||
emit,
|
||||
|
|
@ -130,3 +133,10 @@ final class const DeviceSettingsEventBrowserViewChosen(
|
|||
@override
|
||||
List<Object?> get props => [studysetId, detailed];
|
||||
}
|
||||
|
||||
/// The person dragged the editor beside a PDF to take [share] of the room.
|
||||
final class const DeviceSettingsEventPdfSidebarDragged(final double share)
|
||||
extends DeviceSettingsEvent {
|
||||
@override
|
||||
List<Object?> get props => [share];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,10 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
|
|||
final String url => url,
|
||||
_ => null,
|
||||
},
|
||||
pdfSidebarShare: switch (json['pdfSidebarShare']) {
|
||||
final num share when share > 0 && share < 1 => share.toDouble(),
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
} on FormatException catch (error) {
|
||||
log('The device settings are unreadable', name: 'Settings', error: error);
|
||||
|
|
@ -85,6 +89,7 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
|
|||
'assistantTarget': settings.assistantTarget.name,
|
||||
'searchEngine': ?settings.searchEngine,
|
||||
'customSearchUrl': ?settings.customSearchUrl,
|
||||
'pdfSidebarShare': ?settings.pdfSidebarShare,
|
||||
}),
|
||||
flush: true,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ final class const DeviceSettings({
|
|||
/// The address a custom search engine searches, with `%s` for the
|
||||
/// question; null without.
|
||||
final String? customSearchUrl,
|
||||
|
||||
/// How much of the room beside a PDF its editor takes, from 0 to 1, as
|
||||
/// last dragged; null for the reader's default width.
|
||||
final double? pdfSidebarShare,
|
||||
}) with Equatable {
|
||||
/// These settings with [appearance] instead.
|
||||
DeviceSettings withAppearance(Appearance appearance) =>
|
||||
|
|
@ -92,6 +96,11 @@ final class const DeviceSettings({
|
|||
DeviceSettings withSearchEngine(String? name, {String? customUrl}) =>
|
||||
_copy(searchEngine: name, customSearchUrl: customUrl);
|
||||
|
||||
/// These settings with the editor beside a PDF taking [share] of the
|
||||
/// room.
|
||||
DeviceSettings withPdfSidebarShare(double share) =>
|
||||
_copy(pdfSidebarShare: share);
|
||||
|
||||
DeviceSettings _copy({
|
||||
Appearance? appearance,
|
||||
Object? language = _keep,
|
||||
|
|
@ -102,6 +111,7 @@ final class const DeviceSettings({
|
|||
AssistantTarget? assistantTarget,
|
||||
Object? searchEngine = _keep,
|
||||
Object? customSearchUrl = _keep,
|
||||
double? pdfSidebarShare,
|
||||
}) => DeviceSettings(
|
||||
appearance: appearance ?? this.appearance,
|
||||
language: identical(language, _keep) ? this.language : language as String?,
|
||||
|
|
@ -116,6 +126,7 @@ final class const DeviceSettings({
|
|||
customSearchUrl: identical(customSearchUrl, _keep)
|
||||
? this.customSearchUrl
|
||||
: customSearchUrl as String?,
|
||||
pdfSidebarShare: pdfSidebarShare ?? this.pdfSidebarShare,
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -129,6 +140,7 @@ final class const DeviceSettings({
|
|||
assistantTarget,
|
||||
searchEngine,
|
||||
customSearchUrl,
|
||||
pdfSidebarShare,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/core_blocks.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/layout/window_size.dart';
|
||||
|
|
@ -149,7 +152,20 @@ class _ReaderState() extends State<_Reader> {
|
|||
var _started = false;
|
||||
var _searching = false;
|
||||
var _fullscreen = false;
|
||||
var _sidebarWidth = _sidebarDefault;
|
||||
|
||||
/// The editor's width as dragged here; null until dragged, when it is
|
||||
/// the share of the room last dragged on this device, else the default.
|
||||
double? _sidebarWidth;
|
||||
|
||||
/// While the divider is dragged: the editor's width and the pointer's
|
||||
/// position when the drag started.
|
||||
({double width, double x})? _drag;
|
||||
|
||||
/// The editor blocks of the panels, with screenshots of the pages: made
|
||||
/// anew only when the app's change, so dragging the divider (a rebuild
|
||||
/// each move) doesn't make every editor below rebuild its services.
|
||||
EditorBlocks? _appBlocks;
|
||||
EditorBlocks? _panelBlocks;
|
||||
var _sheetOpen = false;
|
||||
var _lastPage = 0;
|
||||
|
||||
|
|
@ -157,6 +173,9 @@ class _ReaderState() extends State<_Reader> {
|
|||
static const _sidebarMin = 300.0;
|
||||
static const _viewerMin = 360.0;
|
||||
|
||||
/// The rail beside the pages and its line.
|
||||
static const _railWidth = 57.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
|
@ -361,10 +380,15 @@ class _ReaderState() extends State<_Reader> {
|
|||
],
|
||||
);
|
||||
// The panels' editors take screenshots of the pages too.
|
||||
final appBlocks = blocksOf(context);
|
||||
if (!identical(appBlocks, _appBlocks)) {
|
||||
_appBlocks = appBlocks;
|
||||
_panelBlocks = appBlocks.plus(
|
||||
screenshotBlocks(_capture, widget.state.source.id),
|
||||
);
|
||||
}
|
||||
final panels = EditorBlocksScope(
|
||||
blocks: blocksOf(
|
||||
context,
|
||||
).plus(screenshotBlocks(_capture, widget.state.source.id)),
|
||||
blocks: _panelBlocks!,
|
||||
child: PdfReaderPanels(
|
||||
source: widget.state.source,
|
||||
controller: _controller,
|
||||
|
|
@ -380,15 +404,27 @@ class _ReaderState() extends State<_Reader> {
|
|||
body: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// The sidebar as wide as dragged, leaving the pages room.
|
||||
final width = _sidebarWidth.clamp(
|
||||
_sidebarMin,
|
||||
(constraints.maxWidth - _viewerMin - 57).clamp(
|
||||
_sidebarMin,
|
||||
double.infinity,
|
||||
),
|
||||
// The sidebar as wide as dragged, or the share of the room
|
||||
// last dragged on this device, leaving the pages room.
|
||||
final room =
|
||||
constraints.maxWidth - _railWidth - _SidebarHandle.width;
|
||||
final share = context.select<DeviceSettingsBloc, double?>(
|
||||
(bloc) => bloc.state.pdfSidebarShare,
|
||||
);
|
||||
return Row(
|
||||
double widthOf(double? dragged) =>
|
||||
(dragged ?? (share == null ? _sidebarDefault : share * room))
|
||||
.clamp(
|
||||
_sidebarMin,
|
||||
math.max(_sidebarMin, room - _viewerMin),
|
||||
)
|
||||
.toDouble();
|
||||
final width = widthOf(_sidebarWidth);
|
||||
return MouseRegion(
|
||||
// While dragging, the pointer keeps its look over the pages.
|
||||
cursor: _drag == null
|
||||
? MouseCursor.defer
|
||||
: SystemMouseCursors.resizeColumn,
|
||||
child: Row(
|
||||
children: [
|
||||
_Rail(
|
||||
title: widget.state.source.content.title,
|
||||
|
|
@ -398,21 +434,32 @@ class _ReaderState() extends State<_Reader> {
|
|||
),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(child: viewer),
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeColumn,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onHorizontalDragUpdate: (details) => setState(
|
||||
() => _sidebarWidth = width - details.delta.dx,
|
||||
),
|
||||
child: const SizedBox(
|
||||
width: 10,
|
||||
child: VerticalDivider(width: 10),
|
||||
),
|
||||
_SidebarHandle(
|
||||
dragging: _drag != null,
|
||||
// From where the drag started: every move counts.
|
||||
onStart: (x) => setState(() {
|
||||
_drag = (width: width, x: x);
|
||||
}),
|
||||
onMove: (x) => setState(() {
|
||||
_sidebarWidth = _drag!.width - (x - _drag!.x);
|
||||
}),
|
||||
onEnd: () {
|
||||
// A press let go of without dragging ends nothing.
|
||||
if (_drag == null) {
|
||||
return;
|
||||
}
|
||||
setState(() => _drag = null);
|
||||
// As last moved, which may not have been built yet.
|
||||
context.read<DeviceSettingsBloc>().add(
|
||||
DeviceSettingsEventPdfSidebarDragged(
|
||||
widthOf(_sidebarWidth) / room,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
SizedBox(width: width, child: panels),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -1111,3 +1158,70 @@ class const _ChapterSuggestion({
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The divider between the pages and the editor, dragged to share the room
|
||||
/// between them: 16 dp to take hold of, its line darkening and a grip
|
||||
/// showing under the pointer and while dragged.
|
||||
class const _SidebarHandle({
|
||||
required final bool dragging,
|
||||
required final ValueChanged<double> onStart,
|
||||
required final ValueChanged<double> onMove,
|
||||
required final VoidCallback onEnd,
|
||||
}) extends StatefulWidget {
|
||||
/// How wide it is to take hold of.
|
||||
static const width = 16.0;
|
||||
|
||||
@override
|
||||
State<_SidebarHandle> createState() => _SidebarHandleState();
|
||||
}
|
||||
|
||||
class _SidebarHandleState() extends State<_SidebarHandle> {
|
||||
var _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final active = _hovered || widget.dragging;
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeColumn,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
// From where it was pressed, so the divider never trails the
|
||||
// pointer by the distance a drag needs to be told from a tap.
|
||||
dragStartBehavior: DragStartBehavior.down,
|
||||
onHorizontalDragStart: (details) =>
|
||||
widget.onStart(details.globalPosition.dx),
|
||||
onHorizontalDragUpdate: (details) =>
|
||||
widget.onMove(details.globalPosition.dx),
|
||||
onHorizontalDragEnd: (_) => widget.onEnd(),
|
||||
onHorizontalDragCancel: widget.onEnd,
|
||||
child: SizedBox(
|
||||
width: _SidebarHandle.width,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
VerticalDivider(
|
||||
width: 1,
|
||||
color: active ? colors.outline : null,
|
||||
),
|
||||
AnimatedOpacity(
|
||||
opacity: active ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 120),
|
||||
child: Container(
|
||||
width: 6,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.outline,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,18 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test("keeps the PDF sidebar's share of the room, a share only", () async {
|
||||
expect((await store.read()).pdfSidebarShare, isNull);
|
||||
|
||||
await store.write(const DeviceSettings().withPdfSidebarShare(0.4));
|
||||
expect((await FileDeviceSettingsStore(file).read()).pdfSidebarShare, 0.4);
|
||||
|
||||
for (final share in ['1.5', '0', '"wide"']) {
|
||||
await file.writeAsString('{"pdfSidebarShare": $share}');
|
||||
expect((await store.read()).pdfSidebarShare, isNull, reason: share);
|
||||
}
|
||||
});
|
||||
|
||||
test('leaves no temporary file behind', () async {
|
||||
await store.write(const DeviceSettings(appearance: Appearance.dark));
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@ import 'dart:convert';
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
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/device_settings/model/device_settings.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
|
|
@ -27,6 +29,7 @@ import '../../../support/fake_pdf_source_gateway.dart';
|
|||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/fake_tag_gateway.dart';
|
||||
import '../../../support/memory_asset_file_cache.dart';
|
||||
import '../../../support/memory_device_settings_store.dart';
|
||||
import '../../../support/pump_app.dart';
|
||||
import '../../../support/test_dependencies.dart';
|
||||
|
||||
|
|
@ -86,6 +89,7 @@ void main() {
|
|||
WidgetTester tester,
|
||||
String id, {
|
||||
Size size = const Size(1400, 1000),
|
||||
MemoryDeviceSettingsStore? settings,
|
||||
}) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
|
|
@ -101,6 +105,8 @@ void main() {
|
|||
noteGateway: noteGateway,
|
||||
tagGateway: tagGateway,
|
||||
pdfEngine: engine,
|
||||
deviceSettingsStore: settings,
|
||||
deviceSettings: await settings?.read() ?? const DeviceSettings(),
|
||||
),
|
||||
location: RoutePaths.pdf(id),
|
||||
);
|
||||
|
|
@ -138,6 +144,39 @@ void main() {
|
|||
expect(await furthest(), {id: 7});
|
||||
});
|
||||
|
||||
testWidgets('the divider beside the pages follows the pointer, and the '
|
||||
'share of the room it leaves is kept for every PDF', (tester) async {
|
||||
final id = await stored();
|
||||
final settings = MemoryDeviceSettingsStore();
|
||||
await read(tester, id, settings: settings);
|
||||
final handle = find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is MouseRegion &&
|
||||
widget.cursor == SystemMouseCursors.resizeColumn,
|
||||
);
|
||||
// The window less the rail, 57 wide, and the handle, 16.
|
||||
const room = 1400.0 - 57 - 16;
|
||||
expect(tester.getTopLeft(handle).dx, 1400 - 16 - 360);
|
||||
|
||||
await tester.drag(
|
||||
handle,
|
||||
const Offset(-200, 0),
|
||||
kind: PointerDeviceKind.mouse,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.getTopLeft(handle).dx, 1400 - 16 - 560);
|
||||
expect(settings.writes.last.pdfSidebarShare, closeTo(560 / room, 1e-9));
|
||||
|
||||
// Opened again in a narrower window, the sidebar takes the same share.
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await read(tester, id, size: const Size(1200, 1000), settings: settings);
|
||||
const narrower = 1200.0 - 57 - 16;
|
||||
expect(
|
||||
tester.getTopLeft(handle).dx,
|
||||
closeTo(1200 - 16 - 560 / room * narrower, 1e-6),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('opens where reading left off', (tester) async {
|
||||
final id = await stored(readTo: 12);
|
||||
await read(tester, id);
|
||||
|
|
|
|||
Loading…
Reference in a new issue