mirror of
https://gitlab.com/famedly/fluffychat.git
synced 2024-11-23 12:39:29 +01:00
feat: support import of Emoji packs as zip file
Signed-off-by: The one with the braid <the-one@with-the-braid.cf>
This commit is contained in:
parent
b4fcb5b0d9
commit
cb7f7e28d8
@ -14,6 +14,14 @@
|
||||
"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",
|
||||
"about": "About",
|
||||
"@about": {
|
||||
"type": "text",
|
||||
|
343
lib/pages/settings_emotes/import_archive_dialog.dart
Normal file
343
lib/pages/settings_emotes/import_archive_dialog.dart
Normal file
@ -0,0 +1,343 @@
|
||||
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.fileNameOnly),
|
||||
)
|
||||
.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 _ImageFleError(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 _ImageFleError(
|
||||
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 _ImageFleError extends StatelessWidget {
|
||||
final String name;
|
||||
|
||||
const _ImageFleError({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 {
|
||||
String get fileNameOnly {
|
||||
int start = indexOf('/');
|
||||
if (start == -1) {
|
||||
start = 0;
|
||||
} else {
|
||||
start++;
|
||||
}
|
||||
|
||||
int end = lastIndexOf('.');
|
||||
if (end == -1) end = length;
|
||||
|
||||
return substring(start, end)
|
||||
.toLowerCase()
|
||||
.replaceAll(RegExp(r'[^-\w]'), '_');
|
||||
}
|
||||
}
|
@ -1,3 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:adaptive_dialog/adaptive_dialog.dart';
|
||||
@ -5,13 +9,21 @@ 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:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:vrouter/vrouter.dart';
|
||||
|
||||
import 'package:fluffychat/utils/client_manager.dart';
|
||||
import 'package:fluffychat/utils/platform_infos.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 +33,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 +58,7 @@ class EmotesSettingsController extends State<EmotesSettings> {
|
||||
}
|
||||
|
||||
ImagePackContent? _pack;
|
||||
|
||||
ImagePackContent? get pack {
|
||||
if (_pack != null) {
|
||||
return _pack;
|
||||
@ -52,7 +67,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 +176,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 +213,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 +277,99 @@ 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;
|
||||
|
||||
if (kIsWeb || PlatformInfos.isMobile) {
|
||||
await Share.shareXFiles(
|
||||
[XFile(fileName, bytes: Uint8List.fromList(output))],
|
||||
);
|
||||
} else {
|
||||
String? savePath = await FilePicker.platform
|
||||
.saveFile(fileName: fileName, allowedExtensions: ['zip']);
|
||||
|
||||
if (savePath == null) {
|
||||
// workaround for broken `xdg-desktop-portal-termfilechooser`
|
||||
if (PlatformInfos.isLinux) {
|
||||
final dir = await getDownloadsDirectory();
|
||||
if (dir == null) return;
|
||||
savePath = dir.uri.resolve(fileName).toFilePath();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final file = File(savePath);
|
||||
await file.writeAsBytes(output);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Saved emote pack to $savePath !')),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -33,6 +33,20 @@ class EmotesSettingsView extends StatelessWidget {
|
||||
body: MaxWidthBody(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
if (!controller.readonly)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 8.0,
|
||||
),
|
||||
child: ListTile(
|
||||
title: Text(L10n.of(context)!.importFromZipFile),
|
||||
trailing: IconButton(
|
||||
tooltip: L10n.of(context)!.importZipFile,
|
||||
icon: const Icon(Icons.file_open),
|
||||
onPressed: controller.importEmojiZip,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!controller.readonly)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@ -203,6 +217,11 @@ class EmotesSettingsView extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
title: Text(L10n.of(context)!.exportEmotePack),
|
||||
onTap: controller.exportAsZip,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -13,10 +13,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: adaptive_dialog
|
||||
sha256: "16040e94819f6f7b2f5c6478ac3eb145c513ebdff733ac5043ff9b6d8a63247e"
|
||||
sha256: "2dc70b30899398ed0d2949e1ea9b1ee467fe700b4181ec33457737eaaf06b488"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.0-x-macos-beta.0"
|
||||
version: "1.9.0-x-macos-beta.1"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -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 New Issue
Block a user