nc-photos/app/lib/widget/image_editor.dart
2024-05-28 23:56:07 +08:00

414 lines
12 KiB
Dart

import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:kiwi/kiwi.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/cache_manager_util.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/entity/pref.dart';
import 'package:nc_photos/help_utils.dart' as help_util;
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/np_api_util.dart';
import 'package:nc_photos/object_extension.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/url_launcher_util.dart';
import 'package:nc_photos/widget/handler/permission_handler.dart';
import 'package:nc_photos/widget/image_editor/color_toolbar.dart';
import 'package:nc_photos/widget/image_editor/crop_controller.dart';
import 'package:nc_photos/widget/image_editor/transform_toolbar.dart';
import 'package:nc_photos/widget/image_editor_persist_option_dialog.dart';
import 'package:np_platform_image_processor/np_platform_image_processor.dart';
import 'package:np_platform_raw_image/np_platform_raw_image.dart';
import 'package:np_ui/np_ui.dart';
class ImageEditorArguments {
const ImageEditorArguments(this.account, this.file);
final Account account;
final FileDescriptor file;
}
class ImageEditor extends StatefulWidget {
static const routeName = "/image-editor";
static Route buildRoute(ImageEditorArguments args) => MaterialPageRoute(
builder: (context) => ImageEditor.fromArgs(args),
);
const ImageEditor({
super.key,
required this.account,
required this.file,
});
ImageEditor.fromArgs(ImageEditorArguments args, {Key? key})
: this(
key: key,
account: args.account,
file: args.file,
);
@override
createState() => _ImageEditorState();
final Account account;
final FileDescriptor file;
}
class _ImageEditorState extends State<ImageEditor> {
@override
initState() {
super.initState();
_initImage();
_ensurePermission().then((value) {
if (value && mounted) {
final c = KiwiContainer().resolve<DiContainer>();
if (!c.pref.hasShownSaveEditResultDialogOr()) {
_showSaveEditResultDialog(context);
}
}
});
}
Future<bool> _ensurePermission() async {
if (!await const PermissionHandler().ensureStorageWritePermission()) {
if (mounted) {
Navigator.of(context).pop();
}
return false;
} else {
return true;
}
}
@override
build(BuildContext context) => Theme(
data: buildDarkTheme(context),
child: AnnotatedRegion<SystemUiOverlayStyle>(
value: const SystemUiOverlayStyle(
systemNavigationBarColor: Colors.black,
systemNavigationBarIconBrightness: Brightness.dark,
),
child: Scaffold(
body: Builder(
builder: _buildContent,
),
),
),
);
Future<void> _initImage() async {
final fileInfo = await LargeImageCacheManager.inst
.getFileFromCache(api_util.getFilePreviewUrl(
widget.account,
widget.file,
width: k.photoLargeSize,
height: k.photoLargeSize,
isKeepAspectRatio: true,
));
// no need to set shouldfixOrientation because the previews are always in
// the correct orientation
_src = await ImageLoader.loadUri(
"file://${fileInfo!.file.path}",
_previewWidth,
_previewHeight,
ImageLoaderResizeMethod.fit,
isAllowSwapSide: true,
);
if (mounted) {
setState(() {
_isDoneInit = true;
});
}
}
Widget _buildContent(BuildContext context) {
return PopScope(
canPop: false,
onPopInvoked: (didPop) {
if (!didPop) {
_onBackButton(context);
}
},
child: ColoredBox(
color: Colors.black,
child: Column(
children: [
_buildAppBar(context),
Expanded(
child: _isDoneInit
? _isCropMode
? CropController(
// crop always work on the src, otherwise we'll be
// cropping repeatedly
image: _src,
initialState: _cropFilter,
onCropChanged: (cropFilter) {
_cropFilter = cropFilter;
_applyFilters();
},
)
: Image(
image: (_dst ?? _src).run((obj) =>
PixelImage(obj.pixel, obj.width, obj.height)),
fit: BoxFit.contain,
gaplessPlayback: true,
)
: Container(),
),
if (_activeTool == _ToolType.color)
ColorToolbar(
initialState: _colorFilters,
onActiveFiltersChanged: (colorFilters) {
_colorFilters = colorFilters.toList();
_applyFilters();
},
)
else if (_activeTool == _ToolType.transform)
TransformToolbar(
initialState: _transformFilters,
onActiveFiltersChanged: (transformFilters) {
_transformFilters = transformFilters.toList();
_applyFilters();
},
isCropModeChanged: (value) {
setState(() {
_isCropMode = value;
});
},
onCropToolDeactivated: () {
_cropFilter = null;
_applyFilters();
},
),
const SizedBox(height: 4),
_buildToolBar(context),
],
),
),
);
}
Widget _buildAppBar(BuildContext context) => AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: BackButton(onPressed: () => _onBackButton(context)),
title: Text(L10n.global().imageEditTitle),
actions: [
if (_isModified)
IconButton(
icon: const Icon(Icons.save_outlined),
tooltip: L10n.global().saveTooltip,
onPressed: () => _onSavePressed(context),
),
IconButton(
icon: const Icon(Icons.help_outline),
tooltip: L10n.global().helpTooltip,
onPressed: () {
launch(help_util.editPhotosUrl);
},
),
],
);
Widget _buildToolBar(BuildContext context) {
return Align(
alignment: AlignmentDirectional.centerStart,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
const SizedBox(width: 16),
_ToolButton(
icon: Icons.palette_outlined,
label: L10n.global().imageEditToolbarColorLabel,
isSelected: _activeTool == _ToolType.color,
onPressed: () {
setState(() {
_setActiveTool(_ToolType.color);
});
},
),
_ToolButton(
icon: Icons.transform_outlined,
label: L10n.global().imageEditToolbarTransformLabel,
isSelected: _activeTool == _ToolType.transform,
onPressed: () {
setState(() {
_setActiveTool(_ToolType.transform);
});
},
),
const SizedBox(width: 16),
],
),
),
);
}
Future<void> _onBackButton(BuildContext context) async {
if (!_isModified) {
Navigator.of(context).pop();
return;
}
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(L10n.global().imageEditDiscardDialogTitle),
content: Text(L10n.global().imageEditDiscardDialogContent),
actions: [
TextButton(
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
onPressed: () {
Navigator.of(context).pop(false);
},
),
TextButton(
child: Text(L10n.global().discardButtonLabel),
onPressed: () {
Navigator.of(context).pop(true);
},
),
],
),
);
if (result == true) {
Navigator.of(context).pop();
}
}
Future<void> _onSavePressed(BuildContext context) async {
final c = KiwiContainer().resolve<DiContainer>();
await ImageProcessor.filter(
"${widget.account.url}/${widget.file.fdPath}",
widget.file.filename,
4096,
3072,
_buildFilterList(),
headers: {
"Authorization": AuthUtil.fromAccount(widget.account).toHeaderValue(),
},
isSaveToServer: c.pref.isSaveEditResultToServerOr(),
);
Navigator.of(context).pop();
}
Future<void> _showSaveEditResultDialog(BuildContext context) async {
await showDialog(
context: context,
barrierDismissible: false,
builder: (context) =>
const ImageEditorPersistOptionDialog(isFromEditor: true),
);
}
void _setActiveTool(_ToolType tool) {
_activeTool = tool;
_isCropMode = false;
}
List<ImageFilter> _buildFilterList() {
return [
if (_cropFilter != null) _cropFilter!.toImageFilter()!,
..._transformFilters.map((f) => f.toImageFilter()).whereNotNull(),
..._colorFilters.map((f) => f.toImageFilter()),
];
}
Future<void> _applyFilters() async {
final result = await ImageProcessor.filterPreview(_src, _buildFilterList());
setState(() {
_dst = result;
});
}
bool get _isModified =>
_cropFilter != null ||
_transformFilters.isNotEmpty ||
_colorFilters.isNotEmpty;
bool _isDoneInit = false;
late final Rgba8Image _src;
Rgba8Image? _dst;
var _activeTool = _ToolType.color;
var _isCropMode = false;
var _colorFilters = <ColorArguments>[];
var _transformFilters = <TransformArguments>[];
TransformArguments? _cropFilter;
}
enum _ToolType {
color,
transform,
}
class _ToolButton extends StatelessWidget {
const _ToolButton({
required this.icon,
required this.label,
required this.onPressed,
this.isSelected = false,
});
@override
build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(24)),
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onPressed,
child: Container(
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.secondaryContainer
: null,
// borderRadius: const BorderRadius.all(Radius.circular(24)),
),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
alignment: Alignment.center,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
color: isSelected
? Theme.of(context).colorScheme.onSecondaryContainer
: M3.of(context).filterChip.disabled.labelText,
size: 18,
),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
color: isSelected
? Theme.of(context).colorScheme.onSecondaryContainer
: Theme.of(context).colorScheme.onSurface,
),
),
],
),
),
),
),
),
);
}
final IconData icon;
final String label;
final VoidCallback? onPressed;
final bool isSelected;
}
const _previewWidth = 480;
const _previewHeight = 360;