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.
|
- **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.
|
- **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)`.
|
- **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.
|
- **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).
|
- **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`.
|
- **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),
|
state.withSearchEngine(event.name, customUrl: event.customUrl),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
on<DeviceSettingsEventPdfSidebarDragged>(
|
||||||
|
(event, emit) => _apply(emit, state.withPdfSidebarShare(event.share)),
|
||||||
|
);
|
||||||
on<DeviceSettingsEventBrowserViewChosen>(
|
on<DeviceSettingsEventBrowserViewChosen>(
|
||||||
(event, emit) => _apply(
|
(event, emit) => _apply(
|
||||||
emit,
|
emit,
|
||||||
|
|
@ -130,3 +133,10 @@ final class const DeviceSettingsEventBrowserViewChosen(
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [studysetId, detailed];
|
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,
|
final String url => url,
|
||||||
_ => null,
|
_ => null,
|
||||||
},
|
},
|
||||||
|
pdfSidebarShare: switch (json['pdfSidebarShare']) {
|
||||||
|
final num share when share > 0 && share < 1 => share.toDouble(),
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
} on FormatException catch (error) {
|
} on FormatException catch (error) {
|
||||||
log('The device settings are unreadable', name: 'Settings', error: 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,
|
'assistantTarget': settings.assistantTarget.name,
|
||||||
'searchEngine': ?settings.searchEngine,
|
'searchEngine': ?settings.searchEngine,
|
||||||
'customSearchUrl': ?settings.customSearchUrl,
|
'customSearchUrl': ?settings.customSearchUrl,
|
||||||
|
'pdfSidebarShare': ?settings.pdfSidebarShare,
|
||||||
}),
|
}),
|
||||||
flush: true,
|
flush: true,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,10 @@ final class const DeviceSettings({
|
||||||
/// The address a custom search engine searches, with `%s` for the
|
/// The address a custom search engine searches, with `%s` for the
|
||||||
/// question; null without.
|
/// question; null without.
|
||||||
final String? customSearchUrl,
|
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 {
|
}) with Equatable {
|
||||||
/// These settings with [appearance] instead.
|
/// These settings with [appearance] instead.
|
||||||
DeviceSettings withAppearance(Appearance appearance) =>
|
DeviceSettings withAppearance(Appearance appearance) =>
|
||||||
|
|
@ -92,6 +96,11 @@ final class const DeviceSettings({
|
||||||
DeviceSettings withSearchEngine(String? name, {String? customUrl}) =>
|
DeviceSettings withSearchEngine(String? name, {String? customUrl}) =>
|
||||||
_copy(searchEngine: name, customSearchUrl: 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({
|
DeviceSettings _copy({
|
||||||
Appearance? appearance,
|
Appearance? appearance,
|
||||||
Object? language = _keep,
|
Object? language = _keep,
|
||||||
|
|
@ -102,6 +111,7 @@ final class const DeviceSettings({
|
||||||
AssistantTarget? assistantTarget,
|
AssistantTarget? assistantTarget,
|
||||||
Object? searchEngine = _keep,
|
Object? searchEngine = _keep,
|
||||||
Object? customSearchUrl = _keep,
|
Object? customSearchUrl = _keep,
|
||||||
|
double? pdfSidebarShare,
|
||||||
}) => DeviceSettings(
|
}) => DeviceSettings(
|
||||||
appearance: appearance ?? this.appearance,
|
appearance: appearance ?? this.appearance,
|
||||||
language: identical(language, _keep) ? this.language : language as String?,
|
language: identical(language, _keep) ? this.language : language as String?,
|
||||||
|
|
@ -116,6 +126,7 @@ final class const DeviceSettings({
|
||||||
customSearchUrl: identical(customSearchUrl, _keep)
|
customSearchUrl: identical(customSearchUrl, _keep)
|
||||||
? this.customSearchUrl
|
? this.customSearchUrl
|
||||||
: customSearchUrl as String?,
|
: customSearchUrl as String?,
|
||||||
|
pdfSidebarShare: pdfSidebarShare ?? this.pdfSidebarShare,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -129,6 +140,7 @@ final class const DeviceSettings({
|
||||||
assistantTarget,
|
assistantTarget,
|
||||||
searchEngine,
|
searchEngine,
|
||||||
customSearchUrl,
|
customSearchUrl,
|
||||||
|
pdfSidebarShare,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.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/dependencies_scope.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_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/core_blocks.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
|
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/layout/window_size.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 _started = false;
|
||||||
var _searching = false;
|
var _searching = false;
|
||||||
var _fullscreen = 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 _sheetOpen = false;
|
||||||
var _lastPage = 0;
|
var _lastPage = 0;
|
||||||
|
|
||||||
|
|
@ -157,6 +173,9 @@ class _ReaderState() extends State<_Reader> {
|
||||||
static const _sidebarMin = 300.0;
|
static const _sidebarMin = 300.0;
|
||||||
static const _viewerMin = 360.0;
|
static const _viewerMin = 360.0;
|
||||||
|
|
||||||
|
/// The rail beside the pages and its line.
|
||||||
|
static const _railWidth = 57.0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -361,10 +380,15 @@ class _ReaderState() extends State<_Reader> {
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
// The panels' editors take screenshots of the pages too.
|
// 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(
|
final panels = EditorBlocksScope(
|
||||||
blocks: blocksOf(
|
blocks: _panelBlocks!,
|
||||||
context,
|
|
||||||
).plus(screenshotBlocks(_capture, widget.state.source.id)),
|
|
||||||
child: PdfReaderPanels(
|
child: PdfReaderPanels(
|
||||||
source: widget.state.source,
|
source: widget.state.source,
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
|
|
@ -380,39 +404,62 @@ class _ReaderState() extends State<_Reader> {
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
// The sidebar as wide as dragged, leaving the pages room.
|
// The sidebar as wide as dragged, or the share of the room
|
||||||
final width = _sidebarWidth.clamp(
|
// last dragged on this device, leaving the pages room.
|
||||||
_sidebarMin,
|
final room =
|
||||||
(constraints.maxWidth - _viewerMin - 57).clamp(
|
constraints.maxWidth - _railWidth - _SidebarHandle.width;
|
||||||
_sidebarMin,
|
final share = context.select<DeviceSettingsBloc, double?>(
|
||||||
double.infinity,
|
(bloc) => bloc.state.pdfSidebarShare,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return Row(
|
double widthOf(double? dragged) =>
|
||||||
children: [
|
(dragged ?? (share == null ? _sidebarDefault : share * room))
|
||||||
_Rail(
|
.clamp(
|
||||||
title: widget.state.source.content.title,
|
_sidebarMin,
|
||||||
controller: _controller,
|
math.max(_sidebarMin, room - _viewerMin),
|
||||||
onSearch: _toggleSearch,
|
)
|
||||||
onFullscreen: () => setState(() => _fullscreen = true),
|
.toDouble();
|
||||||
),
|
final width = widthOf(_sidebarWidth);
|
||||||
const VerticalDivider(width: 1),
|
return MouseRegion(
|
||||||
Expanded(child: viewer),
|
// While dragging, the pointer keeps its look over the pages.
|
||||||
MouseRegion(
|
cursor: _drag == null
|
||||||
cursor: SystemMouseCursors.resizeColumn,
|
? MouseCursor.defer
|
||||||
child: GestureDetector(
|
: SystemMouseCursors.resizeColumn,
|
||||||
behavior: HitTestBehavior.opaque,
|
child: Row(
|
||||||
onHorizontalDragUpdate: (details) => setState(
|
children: [
|
||||||
() => _sidebarWidth = width - details.delta.dx,
|
_Rail(
|
||||||
),
|
title: widget.state.source.content.title,
|
||||||
child: const SizedBox(
|
controller: _controller,
|
||||||
width: 10,
|
onSearch: _toggleSearch,
|
||||||
child: VerticalDivider(width: 10),
|
onFullscreen: () => setState(() => _fullscreen = true),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
const VerticalDivider(width: 1),
|
||||||
SizedBox(width: width, child: panels),
|
Expanded(child: viewer),
|
||||||
],
|
_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 {
|
test('leaves no temporary file behind', () async {
|
||||||
await store.write(const DeviceSettings(appearance: Appearance.dark));
|
await store.write(const DeviceSettings(appearance: Appearance.dark));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,12 @@ import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/asset/data/asset_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.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/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/editor/rich_content_editor.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.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_studyset_gateway.dart';
|
||||||
import '../../../support/fake_tag_gateway.dart';
|
import '../../../support/fake_tag_gateway.dart';
|
||||||
import '../../../support/memory_asset_file_cache.dart';
|
import '../../../support/memory_asset_file_cache.dart';
|
||||||
|
import '../../../support/memory_device_settings_store.dart';
|
||||||
import '../../../support/pump_app.dart';
|
import '../../../support/pump_app.dart';
|
||||||
import '../../../support/test_dependencies.dart';
|
import '../../../support/test_dependencies.dart';
|
||||||
|
|
||||||
|
|
@ -86,6 +89,7 @@ void main() {
|
||||||
WidgetTester tester,
|
WidgetTester tester,
|
||||||
String id, {
|
String id, {
|
||||||
Size size = const Size(1400, 1000),
|
Size size = const Size(1400, 1000),
|
||||||
|
MemoryDeviceSettingsStore? settings,
|
||||||
}) async {
|
}) async {
|
||||||
tester.view.physicalSize = size;
|
tester.view.physicalSize = size;
|
||||||
tester.view.devicePixelRatio = 1;
|
tester.view.devicePixelRatio = 1;
|
||||||
|
|
@ -101,6 +105,8 @@ void main() {
|
||||||
noteGateway: noteGateway,
|
noteGateway: noteGateway,
|
||||||
tagGateway: tagGateway,
|
tagGateway: tagGateway,
|
||||||
pdfEngine: engine,
|
pdfEngine: engine,
|
||||||
|
deviceSettingsStore: settings,
|
||||||
|
deviceSettings: await settings?.read() ?? const DeviceSettings(),
|
||||||
),
|
),
|
||||||
location: RoutePaths.pdf(id),
|
location: RoutePaths.pdf(id),
|
||||||
);
|
);
|
||||||
|
|
@ -138,6 +144,39 @@ void main() {
|
||||||
expect(await furthest(), {id: 7});
|
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 {
|
testWidgets('opens where reading left off', (tester) async {
|
||||||
final id = await stored(readTo: 12);
|
final id = await stored(readTo: 12);
|
||||||
await read(tester, id);
|
await read(tester, id);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue