mirror of
https://github.com/krille-chan/fluffychat
synced 2024-09-10 07:05:15 +00:00
Merge pull request #448 from krille-chan/braid/emoji-import-github
feat: support import of Emoji packs as zip file
This commit is contained in:
commit
db144425db
6 changed files with 476 additions and 4 deletions
|
@ -14,6 +14,21 @@
|
|||
"min": {}
|
||||
}
|
||||
},
|
||||
"notAnImage": "Not an image file.",
|
||||
"remove": "Replace",
|
||||
"importNow": "Import now",
|
||||
"importEmojis": "Import Emojis",
|
||||
"importFromZipFile": "Import from .zip file",
|
||||
"importZipFile": "Import .zip file",
|
||||
"exportEmotePack": "Export Emote pack as .zip",
|
||||
"replace": "Replace",
|
||||
"savedEmotePack": "Saved emote pack to {path}!",
|
||||
"@savedEmotePack": {
|
||||
"type": "text",
|
||||
"placeholders": {
|
||||
"path": {}
|
||||
}
|
||||
},
|
||||
"about": "About",
|
||||
"@about": {
|
||||
"type": "text",
|
||||
|
|
344
lib/pages/settings_emotes/import_archive_dialog.dart
Normal file
344
lib/pages/settings_emotes/import_archive_dialog.dart
Normal file
|
@ -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]'), '_');
|
||||
}
|
||||
}
|
|
@ -1,3 +1,6 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:adaptive_dialog/adaptive_dialog.dart';
|
||||
|
@ -5,13 +8,19 @@ import 'package:collection/collection.dart';
|
|||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:future_loading_dialog/future_loading_dialog.dart';
|
||||
import 'package:http/http.dart' hide Client;
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:vrouter/vrouter.dart';
|
||||
|
||||
import 'package:fluffychat/utils/client_manager.dart';
|
||||
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_file_extension.dart';
|
||||
import '../../widgets/matrix.dart';
|
||||
import 'import_archive_dialog.dart';
|
||||
import 'settings_emotes_view.dart';
|
||||
|
||||
import 'package:archive/archive.dart'
|
||||
if (dart.library.io) 'package:archive/archive_io.dart';
|
||||
|
||||
class EmotesSettings extends StatefulWidget {
|
||||
const EmotesSettings({Key? key}) : super(key: key);
|
||||
|
||||
|
@ -21,8 +30,10 @@ class EmotesSettings extends StatefulWidget {
|
|||
|
||||
class EmotesSettingsController extends State<EmotesSettings> {
|
||||
String? get roomId => VRouter.of(context).pathParameters['roomid'];
|
||||
|
||||
Room? get room =>
|
||||
roomId != null ? Matrix.of(context).client.getRoomById(roomId!) : null;
|
||||
|
||||
String? get stateKey => VRouter.of(context).pathParameters['state_key'];
|
||||
|
||||
bool showSave = false;
|
||||
|
@ -44,6 +55,7 @@ class EmotesSettingsController extends State<EmotesSettings> {
|
|||
}
|
||||
|
||||
ImagePackContent? _pack;
|
||||
|
||||
ImagePackContent? get pack {
|
||||
if (_pack != null) {
|
||||
return _pack;
|
||||
|
@ -52,7 +64,7 @@ class EmotesSettingsController extends State<EmotesSettings> {
|
|||
return _pack;
|
||||
}
|
||||
|
||||
Future<void> _save(BuildContext context) async {
|
||||
Future<void> save(BuildContext context) async {
|
||||
if (readonly) {
|
||||
return;
|
||||
}
|
||||
|
@ -161,7 +173,7 @@ class EmotesSettingsController extends State<EmotesSettings> {
|
|||
room == null ? false : !(room!.canSendEvent('im.ponies.room_emotes'));
|
||||
|
||||
void saveAction() async {
|
||||
await _save(context);
|
||||
await save(context);
|
||||
setState(() {
|
||||
showSave = false;
|
||||
});
|
||||
|
@ -198,7 +210,7 @@ class EmotesSettingsController extends State<EmotesSettings> {
|
|||
return;
|
||||
}
|
||||
pack!.images[imageCode] = newImageController.value!;
|
||||
await _save(context);
|
||||
await save(context);
|
||||
setState(() {
|
||||
newImageCodeController.text = '';
|
||||
newImageController.value = null;
|
||||
|
@ -262,4 +274,77 @@ class EmotesSettingsController extends State<EmotesSettings> {
|
|||
Widget build(BuildContext context) {
|
||||
return EmotesSettingsView(this);
|
||||
}
|
||||
|
||||
Future<void> importEmojiZip() async {
|
||||
final result = await showFutureLoadingDialog<Archive?>(
|
||||
context: context,
|
||||
future: () async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: [
|
||||
'zip',
|
||||
// TODO: add further encoders
|
||||
],
|
||||
// TODO: migrate to stream, currently brrrr because of `archive_io`.
|
||||
withData: true,
|
||||
);
|
||||
|
||||
if (result == null) return null;
|
||||
|
||||
final buffer = InputStream(result.files.single.bytes);
|
||||
|
||||
final archive = ZipDecoder().decodeBuffer(buffer);
|
||||
|
||||
return archive;
|
||||
},
|
||||
);
|
||||
|
||||
final archive = result.result;
|
||||
if (archive == null) return;
|
||||
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => ImportEmoteArchiveDialog(
|
||||
controller: this,
|
||||
archive: archive,
|
||||
),
|
||||
);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> exportAsZip() async {
|
||||
final client = Matrix.of(context).client;
|
||||
|
||||
await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () async {
|
||||
final pack = _getPack();
|
||||
final archive = Archive();
|
||||
for (final entry in pack.images.entries) {
|
||||
final emote = entry.value;
|
||||
final name = entry.key;
|
||||
final url = emote.url.getDownloadLink(client);
|
||||
final response = await get(url);
|
||||
|
||||
archive.addFile(
|
||||
ArchiveFile(
|
||||
name,
|
||||
response.bodyBytes.length,
|
||||
response.bodyBytes,
|
||||
),
|
||||
);
|
||||
}
|
||||
final fileName =
|
||||
'${pack.pack.displayName ?? client.userID?.localpart ?? 'emotes'}.zip';
|
||||
final output = ZipEncoder().encode(archive);
|
||||
|
||||
if (output == null) return;
|
||||
|
||||
MatrixFile(
|
||||
name: fileName,
|
||||
bytes: Uint8List.fromList(output),
|
||||
).save(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,8 @@ import 'package:fluffychat/widgets/mxc_image.dart';
|
|||
import '../../widgets/matrix.dart';
|
||||
import 'settings_emotes.dart';
|
||||
|
||||
enum PopupMenuEmojiActions { import, export }
|
||||
|
||||
class EmotesSettingsView extends StatelessWidget {
|
||||
final EmotesSettingsController controller;
|
||||
|
||||
|
@ -23,6 +25,31 @@ class EmotesSettingsView extends StatelessWidget {
|
|||
appBar: AppBar(
|
||||
leading: const BackButton(),
|
||||
title: Text(L10n.of(context)!.emoteSettings),
|
||||
actions: [
|
||||
PopupMenuButton<PopupMenuEmojiActions>(
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case PopupMenuEmojiActions.export:
|
||||
controller.exportAsZip();
|
||||
break;
|
||||
case PopupMenuEmojiActions.import:
|
||||
controller.importEmojiZip();
|
||||
break;
|
||||
}
|
||||
},
|
||||
enabled: !controller.readonly,
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: PopupMenuEmojiActions.import,
|
||||
child: Text(L10n.of(context)!.importFromZipFile),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: PopupMenuEmojiActions.export,
|
||||
child: Text(L10n.of(context)!.exportEmotePack),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: controller.showSave
|
||||
? FloatingActionButton(
|
||||
|
|
|
@ -50,7 +50,7 @@ packages:
|
|||
source: hosted
|
||||
version: "2.0.1"
|
||||
archive:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: archive
|
||||
sha256: "0c8368c9b3f0abbc193b9d6133649a614204b528982bebc7026372d61677ce3a"
|
||||
|
|
|
@ -9,6 +9,7 @@ environment:
|
|||
dependencies:
|
||||
adaptive_dialog: ^1.9.0-x-macos-beta.1
|
||||
animations: ^2.0.7
|
||||
archive: ^3.3.7
|
||||
badges: ^2.0.3
|
||||
blurhash_dart: ^1.1.0
|
||||
callkeep: ^0.3.2
|
||||
|
|
Loading…
Reference in a new issue