feat: support import of Emoji packs as zip file
Signed-off-by: The one with the braid <the-one@with-the-braid.cf> Signed-off-by: TheOneWithTheBraid <the-one@with-the-braid.cf>pull/468/head
parent
54ea513f36
commit
0c70017cd8
@ -0,0 +1,344 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:adaptive_dialog/adaptive_dialog.dart';
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'package:fluffychat/pages/settings_emotes/settings_emotes.dart';
|
||||
import 'package:fluffychat/utils/client_manager.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class ImportEmoteArchiveDialog extends StatefulWidget {
|
||||
final EmotesSettingsController controller;
|
||||
final Archive archive;
|
||||
|
||||
const ImportEmoteArchiveDialog({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.archive,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ImportEmoteArchiveDialog> createState() =>
|
||||
_ImportEmoteArchiveDialogState();
|
||||
}
|
||||
|
||||
class _ImportEmoteArchiveDialogState extends State<ImportEmoteArchiveDialog> {
|
||||
Map<ArchiveFile, String> _importMap = {};
|
||||
|
||||
bool _loading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_importFileMap();
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(L10n.of(context)!.importEmojis),
|
||||
content: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.spaceEvenly,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
runSpacing: 8,
|
||||
spacing: 8,
|
||||
children: _importMap.entries
|
||||
.map(
|
||||
(e) => _EmojiImportPreview(
|
||||
key: ValueKey(e.key.name),
|
||||
entry: e,
|
||||
onNameChanged: (name) => _importMap[e.key] = name,
|
||||
onRemove: () =>
|
||||
setState(() => _importMap.remove(e.key)),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _loading ? null : Navigator.of(context).pop,
|
||||
child: Text(L10n.of(context)!.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _loading
|
||||
? null
|
||||
: _importMap.isNotEmpty
|
||||
? _addEmotePack
|
||||
: null,
|
||||
child: Text(L10n.of(context)!.importNow),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _importFileMap() {
|
||||
_importMap = Map.fromEntries(
|
||||
widget.archive.files
|
||||
.where((e) => e.isFile)
|
||||
.map(
|
||||
(e) => MapEntry(e, e.name.emoteNameFromPath),
|
||||
)
|
||||
.sorted(
|
||||
(a, b) => a.value.compareTo(b.value),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addEmotePack() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
});
|
||||
final imports = _importMap;
|
||||
final successfulUploads = <String>{};
|
||||
|
||||
// check for duplicates first
|
||||
|
||||
final skipKeys = [];
|
||||
|
||||
for (final entry in imports.entries) {
|
||||
final imageCode = entry.value;
|
||||
|
||||
if (widget.controller.pack!.images.containsKey(imageCode)) {
|
||||
final completer = Completer<OkCancelResult>();
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) async {
|
||||
final result = await showOkCancelAlertDialog(
|
||||
useRootNavigator: false,
|
||||
context: context,
|
||||
title: L10n.of(context)!.emoteExists,
|
||||
message: imageCode,
|
||||
cancelLabel: L10n.of(context)!.replace,
|
||||
okLabel: L10n.of(context)!.skip,
|
||||
);
|
||||
completer.complete(result);
|
||||
});
|
||||
|
||||
final result = await completer.future;
|
||||
if (result == OkCancelResult.ok) {
|
||||
skipKeys.add(entry.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (final key in skipKeys) {
|
||||
imports.remove(key);
|
||||
}
|
||||
|
||||
for (final entry in imports.entries) {
|
||||
final file = entry.key;
|
||||
final imageCode = entry.value;
|
||||
|
||||
// try {
|
||||
var mxcFile = MatrixImageFile(
|
||||
bytes: file.content,
|
||||
name: file.name,
|
||||
);
|
||||
try {
|
||||
mxcFile = (await mxcFile.generateThumbnail(
|
||||
nativeImplementations: ClientManager.nativeImplementations,
|
||||
))!;
|
||||
} catch (e, s) {
|
||||
Logs().w('Unable to create thumbnail', e, s);
|
||||
}
|
||||
final uri = await Matrix.of(context).client.uploadContent(
|
||||
mxcFile.bytes,
|
||||
filename: mxcFile.name,
|
||||
contentType: mxcFile.mimeType,
|
||||
);
|
||||
|
||||
final info = <String, dynamic>{
|
||||
...mxcFile.info,
|
||||
};
|
||||
|
||||
// normalize width / height to 256, required for stickers
|
||||
if (info['w'] is int && info['h'] is int) {
|
||||
final ratio = info['w'] / info['h'];
|
||||
if (info['w'] > info['h']) {
|
||||
info['w'] = 256;
|
||||
info['h'] = (256.0 / ratio).round();
|
||||
} else {
|
||||
info['h'] = 256;
|
||||
info['w'] = (ratio * 256.0).round();
|
||||
}
|
||||
}
|
||||
widget.controller.pack!.images[imageCode] =
|
||||
ImagePackImageContent.fromJson(<String, dynamic>{
|
||||
'url': uri.toString(),
|
||||
'info': info,
|
||||
});
|
||||
successfulUploads.add(file.name);
|
||||
/*} catch (e) {
|
||||
|
||||
Logs().d('Could not upload emote $imageCode');
|
||||
}*/
|
||||
}
|
||||
|
||||
await widget.controller.save(context);
|
||||
_importMap.removeWhere(
|
||||
(key, value) => successfulUploads.contains(key.name),
|
||||
);
|
||||
|
||||
_loading = false;
|
||||
|
||||
// in case we have unhandled / duplicated emotes left, don't pop
|
||||
if (mounted) setState(() {});
|
||||
if (_importMap.isEmpty) {
|
||||
WidgetsBinding.instance
|
||||
.addPostFrameCallback((_) => Navigator.of(context).pop());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _EmojiImportPreview extends StatefulWidget {
|
||||
final MapEntry<ArchiveFile, String> entry;
|
||||
final ValueChanged<String> onNameChanged;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
const _EmojiImportPreview({
|
||||
Key? key,
|
||||
required this.entry,
|
||||
required this.onNameChanged,
|
||||
required this.onRemove,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<_EmojiImportPreview> createState() => _EmojiImportPreviewState();
|
||||
}
|
||||
|
||||
class _EmojiImportPreviewState extends State<_EmojiImportPreview> {
|
||||
final hasErrorNotifier = ValueNotifier(false);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// TODO: support Lottie here as well ...
|
||||
final controller = TextEditingController(text: widget.entry.value);
|
||||
|
||||
return Stack(
|
||||
alignment: Alignment.topRight,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: widget.onRemove,
|
||||
icon: const Icon(Icons.remove_circle),
|
||||
tooltip: L10n.of(context)!.remove,
|
||||
),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: hasErrorNotifier,
|
||||
builder: (context, hasError, child) {
|
||||
if (hasError) return _ImageFileError(name: widget.entry.key.name);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Image.memory(
|
||||
widget.entry.key.content,
|
||||
height: 64,
|
||||
width: 64,
|
||||
errorBuilder: (context, e, s) {
|
||||
WidgetsBinding.instance
|
||||
.addPostFrameCallback((_) => _setRenderError());
|
||||
|
||||
return _ImageFileError(
|
||||
name: widget.entry.key.name,
|
||||
);
|
||||
},
|
||||
),
|
||||
SizedBox(
|
||||
width: 128,
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'^[-\w]+$'))
|
||||
],
|
||||
autocorrect: false,
|
||||
minLines: 1,
|
||||
maxLines: 1,
|
||||
decoration: InputDecoration(
|
||||
hintText: L10n.of(context)!.emoteShortcode,
|
||||
prefixText: ': ',
|
||||
suffixText: ':',
|
||||
border: const OutlineInputBorder(),
|
||||
prefixStyle: TextStyle(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
suffixStyle: TextStyle(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
onChanged: widget.onNameChanged,
|
||||
onSubmitted: widget.onNameChanged,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
_setRenderError() {
|
||||
hasErrorNotifier.value = true;
|
||||
widget.onRemove.call();
|
||||
}
|
||||
}
|
||||
|
||||
class _ImageFileError extends StatelessWidget {
|
||||
final String name;
|
||||
|
||||
const _ImageFileError({required this.name});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox.square(
|
||||
dimension: 64,
|
||||
child: Tooltip(
|
||||
message: name,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error),
|
||||
Text(
|
||||
L10n.of(context)!.notAnImage,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension on String {
|
||||
/// normalizes a file path into its name only replacing any special character
|
||||
/// [^-\w] with an underscore and removing the extension
|
||||
///
|
||||
/// Used to compute emote name proposal based on file name
|
||||
String get emoteNameFromPath {
|
||||
// ... removing leading path
|
||||
return split(RegExp(r'[/\\]'))
|
||||
.last
|
||||
// ... removing file extension
|
||||
.split('.')
|
||||
.first
|
||||
// ... lowering
|
||||
.toLowerCase()
|
||||
// ... replacing unexpected characters
|
||||
.replaceAll(RegExp(r'[^-\w]'), '_');
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue