Skip to content

Commit 4a235de

Browse files
committed
Add area duplication to other pages
1 parent e8731c0 commit 4a235de

11 files changed

Lines changed: 283 additions & 9 deletions

File tree

api/lib/src/protocol/event.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,9 @@ sealed class DocumentEvent extends ReplayEvent with _$DocumentEvent {
154154

155155
const factory DocumentEvent.areasCreated(List<Area> areas) = AreasCreated;
156156

157+
const factory DocumentEvent.areasDuplicated(Area area, List<String> pages) =
158+
AreasDuplicated;
159+
157160
const factory DocumentEvent.areasRemoved(List<String> areas) = AreasRemoved;
158161

159162
const factory DocumentEvent.areaChanged(

api/lib/src/protocol/event.freezed.dart

Lines changed: 94 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/lib/src/protocol/event.g.dart

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/lib/bloc/document_bloc.dart

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,17 @@ String getInitialArea(DocumentPage? page) {
130130
return page?.areas.firstWhereOrNull((e) => e.isInitial)?.name ?? '';
131131
}
132132

133+
Area _createDuplicatedArea(Area area, List<Area> existingAreas) {
134+
final baseName = area.name.isEmpty ? 'Area' : area.name;
135+
final existingNames = existingAreas.map((e) => e.name).toSet();
136+
var name = baseName;
137+
var count = 1;
138+
while (existingNames.contains(name)) {
139+
name = '$baseName (${count++})';
140+
}
141+
return area.copyWith(name: name);
142+
}
143+
133144
class DocumentBloc extends ReplayBloc<DocumentEvent, DocumentState> {
134145
final _historyReloadRunner = CoalescedAsyncRunner(
135146
delay: const Duration(milliseconds: 50),
@@ -1203,6 +1214,45 @@ class DocumentBloc extends ReplayBloc<DocumentEvent, DocumentState> {
12031214
reset: shouldRepaint,
12041215
);
12051216
}, transformer: sequential());
1217+
on<AreasDuplicated>((event, emit) async {
1218+
final current = state;
1219+
if (current is! DocumentLoadSuccess) return;
1220+
if (!(embedding?.editable ?? true)) return;
1221+
final selectedPages = event.pages.toSet();
1222+
var data = current.data.setPage(current.page, current.pageName).$1;
1223+
var currentPage = current.page;
1224+
var currentPageChanged = false;
1225+
for (final (pageName, realPageName) in data.getPagesWithNames()) {
1226+
if (!selectedPages.contains(pageName) &&
1227+
!selectedPages.contains(realPageName)) {
1228+
continue;
1229+
}
1230+
final page = data.getPage(realPageName);
1231+
if (page == null) continue;
1232+
final duplicatedArea = _createDuplicatedArea(event.area, page.areas);
1233+
final areas = [
1234+
...page.areas.map((e) {
1235+
if (duplicatedArea.isInitial && e.isInitial) {
1236+
return e.copyWith(isInitial: false);
1237+
}
1238+
return e;
1239+
}),
1240+
duplicatedArea,
1241+
];
1242+
final updatedPage = page.copyWith(areas: areas);
1243+
data = data.setPage(updatedPage, realPageName).$1;
1244+
if (realPageName == current.pageName) {
1245+
currentPage = updatedPage;
1246+
currentPageChanged = true;
1247+
}
1248+
}
1249+
_saveState(
1250+
emit,
1251+
state: current.copyWith(data: data, page: currentPage),
1252+
shouldRefresh: () => currentPageChanged,
1253+
reset: currentPageChanged,
1254+
);
1255+
}, transformer: sequential());
12061256
on<AreasRemoved>((event, emit) {
12071257
final current = state;
12081258
if (current is! DocumentLoadSuccess) return;

app/lib/dialogs/area/context.dart

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'package:butterfly/bloc/document_bloc.dart';
22
import 'package:butterfly/cubits/settings.dart';
33
import 'package:butterfly/dialogs/layers.dart';
4+
import 'package:butterfly/dialogs/pages.dart';
45
import 'package:butterfly/helpers/point.dart';
56
import 'package:butterfly/helpers/rect.dart';
67
import 'package:butterfly/widgets/context_menu.dart';
@@ -59,6 +60,25 @@ ContextMenuBuilder buildAreaContextMenu(
5960
);
6061
},
6162
),
63+
ContextMenuItem(
64+
icon: const PhosphorIcon(PhosphorIconsLight.copySimple),
65+
label: AppLocalizations.of(context).duplicate,
66+
onPressed: () async {
67+
final selectedPages = await showDialog<List<String>>(
68+
context: context,
69+
builder: (context) => SelectPagesDialog(
70+
pages: state.data
71+
.getPagesWithNames()
72+
.where((e) => e.$2 != state.pageName)
73+
.toList(),
74+
),
75+
);
76+
if (selectedPages == null) return;
77+
if (!context.mounted) return;
78+
Navigator.of(context).pop();
79+
bloc.add(AreasDuplicated(area, selectedPages));
80+
},
81+
),
6282
ContextMenuItem(
6383
icon: const PhosphorIcon(PhosphorIconsLight.trash),
6484
label: AppLocalizations.of(context).delete,

app/lib/dialogs/import/pages.dart

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,17 @@ class PageDialogCallback {
2727
);
2828
}
2929

30-
class PagesDialog extends StatefulWidget {
30+
class ImportPagesDialog extends StatefulWidget {
3131
final List<ui.Image> pages;
3232
final String? name;
3333

34-
const PagesDialog({super.key, required this.pages, this.name});
34+
const ImportPagesDialog({super.key, required this.pages, this.name});
3535

3636
@override
37-
State<PagesDialog> createState() => _PagesDialogState();
37+
State<ImportPagesDialog> createState() => _ImportPagesDialogState();
3838
}
3939

40-
class _PagesDialogState extends State<PagesDialog> {
40+
class _ImportPagesDialogState extends State<ImportPagesDialog> {
4141
List<int> _selected = const [];
4242
bool _spreadToPages = false,
4343
_createAreas = true,

app/lib/dialogs/pages.dart

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import 'package:butterfly/src/generated/i18n/app_localizations.dart';
2+
import 'package:flutter/material.dart';
3+
4+
class SelectPagesDialog extends StatefulWidget {
5+
final List<(String name, String id)> pages;
6+
7+
const SelectPagesDialog({super.key, required this.pages});
8+
9+
@override
10+
State<SelectPagesDialog> createState() => _SelectPagesDialogState();
11+
}
12+
13+
class _SelectPagesDialogState extends State<SelectPagesDialog> {
14+
final List<String> _selected = [];
15+
16+
@override
17+
Widget build(BuildContext context) {
18+
return AlertDialog(
19+
title: Text(AppLocalizations.of(context).selectPages),
20+
scrollable: true,
21+
content: Column(
22+
mainAxisSize: MainAxisSize.min,
23+
children: [
24+
CheckboxListTile(
25+
title: Text(AppLocalizations.of(context).selectAll),
26+
value: _selected.length == widget.pages.length,
27+
onChanged: (v) {
28+
setState(() {
29+
if (v == true) {
30+
_selected.clear();
31+
_selected.addAll(widget.pages.map((e) => e.$2));
32+
} else {
33+
_selected.clear();
34+
}
35+
});
36+
},
37+
),
38+
...widget.pages.map(
39+
(page) => CheckboxListTile(
40+
title: Text(
41+
page.$1.isEmpty ? AppLocalizations.of(context).page : page.$1,
42+
),
43+
value: _selected.contains(page.$2),
44+
onChanged: (v) {
45+
setState(() {
46+
if (v == true) {
47+
_selected.add(page.$2);
48+
} else {
49+
_selected.remove(page.$2);
50+
}
51+
});
52+
},
53+
),
54+
),
55+
],
56+
),
57+
actions: [
58+
TextButton(
59+
onPressed: () => Navigator.pop(context),
60+
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
61+
),
62+
ElevatedButton(
63+
onPressed: _selected.isEmpty
64+
? null
65+
: () => Navigator.pop(context, _selected),
66+
child: Text(AppLocalizations.of(context).select),
67+
),
68+
],
69+
);
70+
}
71+
}

app/lib/services/import.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -809,7 +809,7 @@ class ImportService {
809809
dialog?.close();
810810
final callback = await showDialog<PageDialogCallback>(
811811
context: context,
812-
builder: (context) => PagesDialog(pages: images, name: name),
812+
builder: (context) => ImportPagesDialog(pages: images, name: name),
813813
);
814814
for (var image in images) {
815815
try {

app/pubspec.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -968,10 +968,10 @@ packages:
968968
dependency: "direct main"
969969
description:
970970
name: path_provider
971-
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
971+
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
972972
url: "https://pub.dev"
973973
source: hosted
974-
version: "2.1.5"
974+
version: "2.1.6"
975975
path_provider_android:
976976
dependency: transitive
977977
description:
@@ -1000,10 +1000,10 @@ packages:
10001000
dependency: transitive
10011001
description:
10021002
name: path_provider_platform_interface
1003-
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
1003+
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
10041004
url: "https://pub.dev"
10051005
source: hosted
1006-
version: "2.1.2"
1006+
version: "2.1.3"
10071007
path_provider_windows:
10081008
dependency: transitive
10091009
description:

app/test/bloc/document_bloc_test.dart

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,28 @@ void main() {
262262
},
263263
);
264264

265+
test('duplicating area adds it to selected pages', () async {
266+
final initialState = bloc.state as DocumentLoadSuccess;
267+
final pages = initialState.data.getPages(true);
268+
final firstPageName = pages.firstWhere((name) => name.endsWith('.Page 1'));
269+
final secondPageName = pages.firstWhere((name) => name.endsWith('.Page 2'));
270+
const area = Area(
271+
name: 'Shared area',
272+
width: 100,
273+
height: 80,
274+
position: Point(10, 20),
275+
);
276+
277+
bloc.add(AreasDuplicated(area, ['Page 1', secondPageName]));
278+
await _settleBlocEvents();
279+
280+
final state = bloc.state as DocumentLoadSuccess;
281+
expect(state.pageName, secondPageName);
282+
expect(state.page.areas, [area]);
283+
expect(state.data.getPage(firstPageName)?.areas, [area]);
284+
expect(state.data.getPage(secondPageName)?.areas, [area]);
285+
});
286+
265287
test('reset state change waits for reload to finish', () async {
266288
await bloc.close();
267289
await currentIndexCubit.close();

0 commit comments

Comments
 (0)