image_editor_page.dart 15 KB

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