image_editor_page.dart 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. import 'dart:io';
  2. import 'dart:math';
  3. import 'dart:typed_data';
  4. import 'package:extended_image/extended_image.dart';
  5. import 'package:flutter/material.dart';
  6. import 'package:image_editor/image_editor.dart';
  7. import 'package:logging/logging.dart';
  8. import 'package:path/path.dart' as path;
  9. import 'package:photo_manager/photo_manager.dart';
  10. import 'package:photos/core/event_bus.dart';
  11. import 'package:photos/db/files_db.dart';
  12. import 'package:photos/events/local_photos_updated_event.dart';
  13. import 'package:photos/models/file.dart' as ente;
  14. import 'package:photos/models/location.dart';
  15. import 'package:photos/services/sync_service.dart';
  16. import 'package:photos/ui/common/loading_widget.dart';
  17. import 'package:photos/ui/components/action_sheet_widget.dart';
  18. import 'package:photos/ui/components/button_widget.dart';
  19. import 'package:photos/ui/components/models/button_type.dart';
  20. import 'package:photos/ui/tools/editor/filtered_image.dart';
  21. import 'package:photos/ui/viewer/file/detail_page.dart';
  22. import 'package:photos/utils/dialog_util.dart';
  23. import 'package:photos/utils/navigation_util.dart';
  24. import 'package:photos/utils/toast_util.dart';
  25. import 'package:syncfusion_flutter_core/theme.dart';
  26. import 'package:syncfusion_flutter_sliders/sliders.dart';
  27. class ImageEditorPage extends StatefulWidget {
  28. final ImageProvider imageProvider;
  29. final DetailPageConfiguration detailPageConfig;
  30. final ente.File originalFile;
  31. const ImageEditorPage(
  32. this.imageProvider,
  33. this.originalFile,
  34. this.detailPageConfig, {
  35. Key? key,
  36. }) : super(key: key);
  37. @override
  38. State<ImageEditorPage> createState() => _ImageEditorPageState();
  39. }
  40. class _ImageEditorPageState extends State<ImageEditorPage> {
  41. static const double kBrightnessDefault = 1;
  42. static const double kBrightnessMin = 0;
  43. static const double kBrightnessMax = 2;
  44. static const double kSaturationDefault = 1;
  45. static const double kSaturationMin = 0;
  46. static const double kSaturationMax = 2;
  47. final _logger = Logger("ImageEditor");
  48. final GlobalKey<ExtendedImageEditorState> editorKey =
  49. GlobalKey<ExtendedImageEditorState>();
  50. double? _brightness = kBrightnessDefault;
  51. double? _saturation = kSaturationDefault;
  52. bool _hasEdited = false;
  53. @override
  54. Widget build(BuildContext context) {
  55. return WillPopScope(
  56. onWillPop: () async {
  57. if (_hasBeenEdited()) {
  58. await _showExitConfirmationDialog();
  59. } else {
  60. replacePage(context, DetailPage(widget.detailPageConfig));
  61. }
  62. return false;
  63. },
  64. child: Scaffold(
  65. appBar: AppBar(
  66. backgroundColor: const Color(0x00000000),
  67. elevation: 0,
  68. actions: _hasBeenEdited()
  69. ? [
  70. IconButton(
  71. padding: const EdgeInsets.only(right: 16, left: 16),
  72. onPressed: () {
  73. editorKey.currentState!.reset();
  74. setState(() {
  75. _brightness = kBrightnessDefault;
  76. _saturation = kSaturationDefault;
  77. });
  78. },
  79. icon: const Icon(Icons.history),
  80. )
  81. ]
  82. : [],
  83. ),
  84. body: Column(
  85. children: [
  86. Expanded(child: _buildImage()),
  87. const Padding(padding: EdgeInsets.all(4)),
  88. Column(
  89. children: [
  90. _buildBrightness(),
  91. _buildSat(),
  92. ],
  93. ),
  94. const Padding(padding: EdgeInsets.all(8)),
  95. _buildBottomBar(),
  96. Padding(padding: EdgeInsets.all(Platform.isIOS ? 16 : 6)),
  97. ],
  98. ),
  99. ),
  100. );
  101. }
  102. bool _hasBeenEdited() {
  103. return _hasEdited ||
  104. _saturation != kSaturationDefault ||
  105. _brightness != kBrightnessDefault;
  106. }
  107. Widget _buildImage() {
  108. return Hero(
  109. tag: widget.detailPageConfig.tagPrefix + widget.originalFile.tag,
  110. child: ExtendedImage(
  111. image: widget.imageProvider,
  112. extendedImageEditorKey: editorKey,
  113. mode: ExtendedImageMode.editor,
  114. fit: BoxFit.contain,
  115. initEditorConfigHandler: (_) => EditorConfig(
  116. maxScale: 8.0,
  117. cropRectPadding: const EdgeInsets.all(20.0),
  118. hitTestSize: 20.0,
  119. cornerColor: const Color.fromRGBO(45, 150, 98, 1),
  120. editActionDetailsIsChanged: (_) {
  121. setState(() {
  122. _hasEdited = true;
  123. });
  124. },
  125. ),
  126. loadStateChanged: (state) {
  127. if (state.extendedImageLoadState == LoadState.completed) {
  128. return FilteredImage(
  129. brightness: _brightness,
  130. saturation: _saturation,
  131. child: state.completedWidget,
  132. );
  133. }
  134. return const EnteLoadingWidget();
  135. },
  136. ),
  137. );
  138. }
  139. Widget _buildBottomBar() {
  140. return Row(
  141. mainAxisAlignment: MainAxisAlignment.spaceAround,
  142. children: [
  143. _buildFlipButton(),
  144. _buildRotateLeftButton(),
  145. _buildRotateRightButton(),
  146. _buildSaveButton(),
  147. ],
  148. );
  149. }
  150. Widget _buildFlipButton() {
  151. final TextStyle subtitle2 = Theme.of(context).textTheme.subtitle2!;
  152. return GestureDetector(
  153. behavior: HitTestBehavior.translucent,
  154. onTap: () {
  155. flip();
  156. },
  157. child: SizedBox(
  158. width: 80,
  159. child: Column(
  160. children: [
  161. Padding(
  162. padding: const EdgeInsets.only(bottom: 2),
  163. child: Icon(
  164. Icons.flip,
  165. color: Theme.of(context).iconTheme.color!.withOpacity(0.8),
  166. size: 20,
  167. ),
  168. ),
  169. const Padding(padding: EdgeInsets.all(2)),
  170. Text(
  171. "Flip",
  172. style: subtitle2.copyWith(
  173. color: subtitle2.color!.withOpacity(0.8),
  174. ),
  175. textAlign: TextAlign.center,
  176. ),
  177. ],
  178. ),
  179. ),
  180. );
  181. }
  182. Widget _buildRotateLeftButton() {
  183. final TextStyle subtitle2 = Theme.of(context).textTheme.subtitle2!;
  184. return GestureDetector(
  185. behavior: HitTestBehavior.translucent,
  186. onTap: () {
  187. rotate(false);
  188. },
  189. child: SizedBox(
  190. width: 80,
  191. child: Column(
  192. children: [
  193. Icon(
  194. Icons.rotate_left,
  195. color: Theme.of(context).iconTheme.color!.withOpacity(0.8),
  196. ),
  197. const Padding(padding: EdgeInsets.all(2)),
  198. Text(
  199. "Rotate left",
  200. style: subtitle2.copyWith(
  201. color: subtitle2.color!.withOpacity(0.8),
  202. ),
  203. textAlign: TextAlign.center,
  204. ),
  205. ],
  206. ),
  207. ),
  208. );
  209. }
  210. Widget _buildRotateRightButton() {
  211. final TextStyle subtitle2 = Theme.of(context).textTheme.subtitle2!;
  212. return GestureDetector(
  213. behavior: HitTestBehavior.translucent,
  214. onTap: () {
  215. rotate(true);
  216. },
  217. child: SizedBox(
  218. width: 80,
  219. child: Column(
  220. children: [
  221. Icon(
  222. Icons.rotate_right,
  223. color: Theme.of(context).iconTheme.color!.withOpacity(0.8),
  224. ),
  225. const Padding(padding: EdgeInsets.all(2)),
  226. Text(
  227. "Rotate right",
  228. style: subtitle2.copyWith(
  229. color: subtitle2.color!.withOpacity(0.8),
  230. ),
  231. textAlign: TextAlign.center,
  232. ),
  233. ],
  234. ),
  235. ),
  236. );
  237. }
  238. Widget _buildSaveButton() {
  239. final TextStyle subtitle2 = Theme.of(context).textTheme.subtitle2!;
  240. return GestureDetector(
  241. behavior: HitTestBehavior.translucent,
  242. onTap: () {
  243. _saveEdits();
  244. },
  245. child: SizedBox(
  246. width: 80,
  247. child: Column(
  248. children: [
  249. Icon(
  250. Icons.save_alt_outlined,
  251. color: Theme.of(context).iconTheme.color!.withOpacity(0.8),
  252. ),
  253. const Padding(padding: EdgeInsets.all(2)),
  254. Text(
  255. "Save copy",
  256. style: subtitle2.copyWith(
  257. color: subtitle2.color!.withOpacity(0.8),
  258. ),
  259. textAlign: TextAlign.center,
  260. ),
  261. ],
  262. ),
  263. ),
  264. );
  265. }
  266. Future<void> _saveEdits() async {
  267. final dialog = createProgressDialog(context, "Saving...");
  268. await dialog.show();
  269. final ExtendedImageEditorState? state = editorKey.currentState;
  270. if (state == null) {
  271. return;
  272. }
  273. final Rect? rect = state.getCropRect();
  274. if (rect == null) {
  275. return;
  276. }
  277. final EditActionDetails action = state.editAction!;
  278. final double radian = action.rotateAngle;
  279. final bool flipHorizontal = action.flipY;
  280. final bool flipVertical = action.flipX;
  281. final Uint8List img = state.rawImageData;
  282. if (img == null) {
  283. _logger.severe("null rawImageData");
  284. showToast(context, "Something went wrong");
  285. return;
  286. }
  287. final ImageEditorOption option = ImageEditorOption();
  288. option.addOption(ClipOption.fromRect(rect));
  289. option.addOption(
  290. FlipOption(horizontal: flipHorizontal, vertical: flipVertical),
  291. );
  292. if (action.hasRotateAngle) {
  293. option.addOption(RotateOption(radian.toInt()));
  294. }
  295. option.addOption(ColorOption.saturation(_saturation!));
  296. option.addOption(ColorOption.brightness(_brightness!));
  297. option.outputFormat = const OutputFormat.png(88);
  298. final DateTime start = DateTime.now();
  299. final Uint8List? result = await ImageEditor.editImage(
  300. image: img,
  301. imageEditorOption: option,
  302. );
  303. _logger.info('result.length = ${result?.length}');
  304. final Duration diff = DateTime.now().difference(start);
  305. _logger.info('image_editor time : $diff');
  306. if (result == null) {
  307. _logger.severe("null result");
  308. showToast(context, "Something went wrong");
  309. return;
  310. }
  311. try {
  312. final fileName =
  313. path.basenameWithoutExtension(widget.originalFile.title!) +
  314. "_edited_" +
  315. DateTime.now().microsecondsSinceEpoch.toString() +
  316. path.extension(widget.originalFile.title!);
  317. //Disabling notifications for assets changing to insert the file into
  318. //files db before triggering a sync.
  319. PhotoManager.stopChangeNotify();
  320. final AssetEntity? newAsset =
  321. await (PhotoManager.editor.saveImage(result, title: fileName));
  322. final newFile = await ente.File.fromAsset(
  323. widget.originalFile.deviceFolder!,
  324. newAsset!,
  325. );
  326. newFile.creationTime = widget.originalFile.creationTime;
  327. newFile.collectionID = widget.originalFile.collectionID;
  328. newFile.location = widget.originalFile.location;
  329. if (!newFile.hasLocation && widget.originalFile.localID != null) {
  330. final assetEntity = await widget.originalFile.getAsset;
  331. if (assetEntity != null) {
  332. final latLong = await assetEntity.latlngAsync();
  333. newFile.location = Location(latLong.latitude, latLong.longitude);
  334. }
  335. }
  336. newFile.generatedID = await FilesDB.instance.insert(newFile);
  337. Bus.instance.fire(LocalPhotosUpdatedEvent([newFile], source: "editSave"));
  338. SyncService.instance.sync();
  339. showShortToast(context, "Edits saved");
  340. _logger.info("Original file " + widget.originalFile.toString());
  341. _logger.info("Saved edits to file " + newFile.toString());
  342. final existingFiles = widget.detailPageConfig.files;
  343. final files = (await widget.detailPageConfig.asyncLoader!(
  344. existingFiles[existingFiles.length - 1].creationTime!,
  345. existingFiles[0].creationTime!,
  346. ))
  347. .files;
  348. // the index could be -1 if the files fetched doesn't contain the newly
  349. // edited files
  350. int selectionIndex =
  351. files.indexWhere((file) => file.generatedID == newFile.generatedID);
  352. if (selectionIndex == -1) {
  353. files.add(newFile);
  354. selectionIndex = files.length - 1;
  355. }
  356. replacePage(
  357. context,
  358. DetailPage(
  359. widget.detailPageConfig.copyWith(
  360. files: files,
  361. selectedIndex: min(selectionIndex, files.length - 1),
  362. ),
  363. ),
  364. );
  365. } catch (e, s) {
  366. showToast(context, "Oops, could not save edits");
  367. _logger.severe(e, s);
  368. } finally {
  369. PhotoManager.startChangeNotify();
  370. }
  371. await dialog.hide();
  372. }
  373. void flip() {
  374. editorKey.currentState?.flip();
  375. }
  376. void rotate(bool right) {
  377. editorKey.currentState?.rotate(right: right);
  378. }
  379. Widget _buildSat() {
  380. final TextStyle subtitle2 = Theme.of(context).textTheme.subtitle2!;
  381. return Container(
  382. padding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
  383. child: Row(
  384. children: [
  385. SizedBox(
  386. width: 40,
  387. child: Text(
  388. "Color",
  389. style: subtitle2.copyWith(
  390. color: subtitle2.color!.withOpacity(0.8),
  391. ),
  392. ),
  393. ),
  394. Expanded(
  395. child: SfSliderTheme(
  396. data: SfSliderThemeData(
  397. activeTrackHeight: 4,
  398. inactiveTrackHeight: 2,
  399. inactiveTrackColor: Colors.grey[900],
  400. activeTrackColor: const Color.fromRGBO(45, 150, 98, 1),
  401. thumbColor: const Color.fromRGBO(45, 150, 98, 1),
  402. thumbRadius: 10,
  403. tooltipBackgroundColor: Colors.grey[900],
  404. ),
  405. child: SfSlider(
  406. onChanged: (value) {
  407. setState(() {
  408. _saturation = value;
  409. });
  410. },
  411. value: _saturation,
  412. enableTooltip: true,
  413. stepSize: 0.01,
  414. min: kSaturationMin,
  415. max: kSaturationMax,
  416. ),
  417. ),
  418. ),
  419. ],
  420. ),
  421. );
  422. }
  423. Widget _buildBrightness() {
  424. final TextStyle subtitle2 = Theme.of(context).textTheme.subtitle2!;
  425. return Container(
  426. padding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
  427. child: Row(
  428. children: [
  429. SizedBox(
  430. width: 40,
  431. child: Text(
  432. "Light",
  433. style: subtitle2.copyWith(
  434. color: subtitle2.color!.withOpacity(0.8),
  435. ),
  436. ),
  437. ),
  438. Expanded(
  439. child: SfSliderTheme(
  440. data: SfSliderThemeData(
  441. activeTrackHeight: 4,
  442. inactiveTrackHeight: 2,
  443. activeTrackColor: const Color.fromRGBO(45, 150, 98, 1),
  444. inactiveTrackColor: Colors.grey[900],
  445. thumbColor: const Color.fromRGBO(45, 150, 98, 1),
  446. thumbRadius: 10,
  447. tooltipBackgroundColor: Colors.grey[900],
  448. ),
  449. child: SfSlider(
  450. onChanged: (value) {
  451. setState(() {
  452. _brightness = value;
  453. });
  454. },
  455. value: _brightness,
  456. enableTooltip: true,
  457. stepSize: 0.01,
  458. min: kBrightnessMin,
  459. max: kBrightnessMax,
  460. ),
  461. ),
  462. ),
  463. ],
  464. ),
  465. );
  466. }
  467. Future<void> _showExitConfirmationDialog() async {
  468. final actionResult = await showActionSheet(
  469. context: context,
  470. buttons: [
  471. const ButtonWidget(
  472. labelText: "Yes, discard changes",
  473. buttonType: ButtonType.critical,
  474. buttonSize: ButtonSize.large,
  475. shouldStickToDarkTheme: true,
  476. buttonAction: ButtonAction.first,
  477. isInAlert: true,
  478. ),
  479. const ButtonWidget(
  480. labelText: "No",
  481. buttonType: ButtonType.secondary,
  482. buttonSize: ButtonSize.large,
  483. buttonAction: ButtonAction.second,
  484. shouldStickToDarkTheme: true,
  485. isInAlert: true,
  486. ),
  487. ],
  488. body: "Do you want to discard the edits you have made?",
  489. actionSheetType: ActionSheetType.defaultActionSheet,
  490. );
  491. if (actionResult?.action != null &&
  492. actionResult!.action == ButtonAction.first) {
  493. replacePage(context, DetailPage(widget.detailPageConfig));
  494. }
  495. }
  496. }