Initial commit
Some checks failed
Main Deploy Workflow / deploy_web (push) Has been cancelled
Main Deploy Workflow / deploy_playstore_internal (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2026-09-11 15:59:56 -03:00
commit 0037b0b9cf
651 changed files with 199705 additions and 0 deletions

View File

@@ -0,0 +1,363 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'dart:async';
import 'package:archive/archive.dart';
import 'package:collection/collection.dart';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/pages/settings_emotes/settings_emotes.dart';
import 'package:tatchat/utils/client_manager.dart';
import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
import 'package:tatchat/widgets/matrix.dart';
import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/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;
double _progress = 0;
@override
void initState() {
_importFileMap();
super.initState();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(L10n.of(context).importEmojis),
content: _loading
? Center(child: CircularProgressIndicator(value: _progress))
: 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 {
final matrix = Matrix.of(context);
setState(() {
_loading = true;
_progress = 0;
});
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) {
setState(() {
_progress += 1 / imports.length;
});
final file = entry.key;
final imageCode = entry.value;
try {
var mxcFile = MatrixImageFile(bytes: file.content, name: file.name);
final thumbnail = (await mxcFile.generateThumbnail(
nativeImplementations: ClientManager.nativeImplementations,
));
if (thumbnail == null) {
Logs().w('Unable to create thumbnail');
} else {
mxcFile = thumbnail;
}
final uri = await matrix.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');
}
}
if (!mounted) return;
await widget.controller.save(context);
_importMap.removeWhere(
(key, value) => successfulUploads.contains(key.name),
);
_loading = false;
_progress = 0;
// 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({
super.key,
required this.entry,
required this.onNameChanged,
required this.onRemove,
});
@override
State<_EmojiImportPreview> createState() => _EmojiImportPreviewState();
}
class _EmojiImportPreviewState extends State<_EmojiImportPreview> {
final hasErrorNotifier = ValueNotifier(false);
final controller = TextEditingController();
@override
void initState() {
super.initState();
controller.text = widget.entry.value;
}
@override
void dispose() {
hasErrorNotifier.dispose();
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// TODO: support Lottie here as well ...
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: .min,
mainAxisAlignment: .center,
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.colorScheme.secondary,
fontWeight: FontWeight.bold,
),
suffixStyle: TextStyle(
color: theme.colorScheme.secondary,
fontWeight: FontWeight.bold,
),
),
onChanged: widget.onNameChanged,
onSubmitted: widget.onNameChanged,
),
),
],
);
},
),
],
);
}
void _setRenderError() {
hasErrorNotifier.value = true;
widget.onRemove.call();
}
}
class _ImageFileError extends StatelessWidget {
final String name;
const _ImageFileError({required this.name});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox.square(
dimension: 64,
child: Tooltip(
message: name,
child: Column(
mainAxisAlignment: .start,
mainAxisSize: .min,
crossAxisAlignment: .center,
children: [
const Icon(Icons.error),
Text(
L10n.of(context).notAnImage,
textAlign: TextAlign.center,
style: theme.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]'), '_');
}
}

View File

@@ -0,0 +1,422 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'dart:async';
import 'package:archive/archive.dart'
if (dart.library.io) 'package:archive/archive_io.dart';
import 'package:file_picker/file_picker.dart';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/utils/client_manager.dart';
import 'package:tatchat/utils/file_selector.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_file_extension.dart';
import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
import 'package:tatchat/widgets/adaptive_dialogs/show_text_input_dialog.dart';
import 'package:tatchat/widgets/future_loading_dialog.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' hide Client;
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import '../../widgets/matrix.dart';
import 'import_archive_dialog.dart';
import 'settings_emotes_view.dart';
class EmotesSettings extends StatefulWidget {
final String? roomId;
const EmotesSettings({required this.roomId, super.key});
@override
EmotesSettingsController createState() => EmotesSettingsController();
}
class EmotesSettingsController extends State<EmotesSettings> {
late final Room? room;
String? stateKey;
List<String>? get packKeys {
final room = this.room;
if (room == null) return null;
final keys = room.states['im.ponies.room_emotes']?.keys.toList() ?? [];
keys.sort();
return keys;
}
@override
void initState() {
super.initState();
room = widget.roomId != null
? Matrix.of(context).client.getRoomById(widget.roomId!)
: null;
setStateKey(packKeys?.firstOrNull, reset: false);
}
void setStateKey(String? key, {reset = true}) {
stateKey = key;
final event = key == null
? null
: room?.getState('im.ponies.room_emotes', key);
final eventPack = event?.content.tryGetMap<String, Object?>('pack');
packDisplayNameController.text =
eventPack?.tryGet<String>('display_name') ?? '';
packAttributionController.text =
eventPack?.tryGet<String>('attribution') ?? '';
if (reset) resetAction();
}
bool showSave = false;
ImagePackContent _getPack() {
final client = Matrix.of(context).client;
final event =
(room != null
? room!.getState('im.ponies.room_emotes', stateKey ?? '')
: client.accountData['im.ponies.user_emotes']) ??
BasicEvent(type: 'm.dummy', content: {});
// make sure we work on a *copy* of the event
return BasicEvent.fromJson(event.toJson()).parsedImagePackContent;
}
ImagePackContent? _pack;
ImagePackContent? get pack {
if (_pack != null) {
return _pack;
}
_pack = _getPack();
return _pack;
}
Future<void> save(BuildContext context) async {
if (readonly) {
return;
}
final client = Matrix.of(context).client;
final result = await showFutureLoadingDialog(
context: context,
future: () => room != null
? client.setRoomStateWithKey(
room!.id,
'im.ponies.room_emotes',
stateKey ?? '',
pack!.toJson(),
)
: client.setAccountData(
client.userID!,
'im.ponies.user_emotes',
pack!.toJson(),
),
);
if (!result.isError) {
setState(() {
showSave = false;
});
}
}
Future<void> setIsGloballyActive(bool active) async {
if (room == null) {
return;
}
final client = Matrix.of(context).client;
final content =
client.accountData['im.ponies.emote_rooms']?.content ??
<String, dynamic>{};
if (active) {
if (content['rooms'] is! Map) {
content['rooms'] = <String, dynamic>{};
}
if (content['rooms'][room!.id] is! Map) {
content['rooms'][room!.id] = <String, dynamic>{};
}
if (content['rooms'][room!.id][stateKey ?? ''] is! Map) {
content['rooms'][room!.id][stateKey ?? ''] = <String, dynamic>{};
}
} else if (content['rooms'] is Map && content['rooms'][room!.id] is Map) {
content['rooms'][room!.id].remove(stateKey ?? '');
}
// and save
await showFutureLoadingDialog(
context: context,
future: () => client.setAccountData(
client.userID!,
'im.ponies.emote_rooms',
content,
),
);
setState(() {});
}
final TextEditingController packDisplayNameController =
TextEditingController();
final TextEditingController packAttributionController =
TextEditingController();
void removeImageAction(String oldImageCode) => setState(() {
pack!.images.remove(oldImageCode);
showSave = true;
});
void toggleUsage(String imageCode, ImagePackUsage usage) {
setState(() {
final usages = pack!.images[imageCode]!.usage ??= List.from(
ImagePackUsage.values,
);
if (!usages.remove(usage)) usages.add(usage);
showSave = true;
});
}
void submitDisplaynameAction() {
if (readonly) return;
packDisplayNameController.text = packDisplayNameController.text.trim();
final input = packDisplayNameController.text;
setState(() {
pack!.pack.displayName = input;
showSave = true;
});
}
void submitAttributionAction() {
if (readonly) return;
packAttributionController.text = packAttributionController.text.trim();
final input = packAttributionController.text;
setState(() {
pack!.pack.attribution = input;
showSave = true;
});
}
void submitImageAction(
String oldImageCode,
ImagePackImageContent image,
TextEditingController controller,
) {
controller.text = controller.text.trim().replaceAll(' ', '-');
final imageCode = controller.text;
if (imageCode == oldImageCode) return;
if (pack!.images.keys.any((k) => k == imageCode && k != oldImageCode)) {
controller.text = oldImageCode;
showOkAlertDialog(
useRootNavigator: false,
context: context,
title: L10n.of(context).emoteExists,
okLabel: L10n.of(context).ok,
);
return;
}
if (!RegExp(r'^[-\w]+$').hasMatch(imageCode)) {
controller.text = oldImageCode;
showOkAlertDialog(
useRootNavigator: false,
context: context,
title: L10n.of(context).emoteInvalid,
okLabel: L10n.of(context).ok,
);
return;
}
setState(() {
pack!.images[imageCode] = image;
pack!.images.remove(oldImageCode);
showSave = true;
});
}
bool isGloballyActive(Client? client) =>
room != null &&
client!.accountData['im.ponies.emote_rooms']?.content
.tryGetMap<String, Object?>('rooms')
?.tryGetMap<String, Object?>(room!.id)
?.tryGetMap<String, Object?>(stateKey ?? '') !=
null;
bool get readonly =>
room != null &&
room?.canChangeStateEvent('im.ponies.room_emotes') == false;
void resetAction() {
setState(() {
_pack = _getPack();
showSave = false;
});
}
Future<void> createImagePack() async {
final room = this.room;
if (room == null) throw Exception('Cannot create image pack without room');
final input = await showTextInputDialog(
context: context,
title: L10n.of(context).newStickerPack,
hintText: L10n.of(context).name,
okLabel: L10n.of(context).create,
);
final name = input?.trim();
if (name == null || name.isEmpty) return;
if (!mounted) return;
final keyName = name.toLowerCase().replaceAll(' ', '_');
if (packKeys?.contains(name) ?? false) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(L10n.of(context).stickerPackNameAlreadyExists)),
);
return;
}
await showFutureLoadingDialog(
context: context,
future: () => room.client.setRoomStateWithKey(
room.id,
'im.ponies.room_emotes',
keyName,
{
'images': {},
'pack': {'display_name': name},
},
),
);
if (!mounted) return;
setState(() {});
await room.client.oneShotSync();
if (!mounted) return;
setState(() {});
}
Future<void> saveAction() async {
await save(context);
setState(() {
showSave = false;
});
}
Future<void> createStickers() async {
final matrix = Matrix.of(context);
final pickedFiles = await selectFiles(
context,
type: FileType.image,
allowMultiple: true,
);
if (pickedFiles.isEmpty) return;
if (!mounted) return;
await showFutureLoadingDialog(
context: context,
futureWithProgress: (setProgress) async {
for (final (i, pickedFile) in pickedFiles.indexed) {
setProgress(i / pickedFiles.length);
var file = MatrixImageFile(
bytes: await pickedFile.readAsBytes(),
name: pickedFile.name,
);
file =
await file.generateThumbnail(
nativeImplementations: ClientManager.nativeImplementations,
) ??
file;
final uri = await matrix.client.uploadContent(
file.bytes,
filename: file.name,
contentType: file.mimeType,
);
setState(() {
final info = <String, dynamic>{...file.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();
}
}
final imageCode = pickedFile.name.split('.').first;
pack!.images[imageCode] = ImagePackImageContent.fromJson(
<String, dynamic>{'url': uri.toString(), 'info': info},
);
});
}
},
);
setState(() {
showSave = true;
});
}
@override
void dispose() {
packDisplayNameController.dispose();
packAttributionController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return EmotesSettingsView(this);
}
Future<void> importEmojiZip() async {
final result = await selectFiles(context, type: FileType.any);
if (result.isEmpty) return;
final buffer = InputMemoryStream(await result.single.readAsBytes());
final archive = ZipDecoder().decodeStream(buffer);
if (!mounted) return;
await showDialog(
context: context,
// breaks [Matrix.of] calls otherwise
useRootNavigator: false,
builder: (context) =>
ImportEmoteArchiveDialog(controller: this, archive: archive),
);
setState(() {});
}
Future<void> exportAsZip() async {
final client = Matrix.of(context).client;
final result = await showFutureLoadingDialog<MatrixFile>(
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 = await emote.url.getDownloadUri(client);
final response = await get(
url,
headers: {'authorization': 'Bearer ${client.accessToken}'},
);
archive.addFile(
ArchiveFile(name, response.bodyBytes.length, response.bodyBytes),
);
}
final fileName =
'${pack.pack.displayName ?? client.userID?.localpart ?? 'emotes'}.zip';
final output = ZipEncoder().encode(archive);
return MatrixFile(name: fileName, bytes: Uint8List.fromList(output));
},
);
final file = result.result;
if (file == null) return;
if (!mounted) return;
file.save(context);
}
}

View File

@@ -0,0 +1,385 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/utils/platform_infos.dart';
import 'package:tatchat/utils/url_launcher.dart';
import 'package:tatchat/widgets/layouts/max_width_body.dart';
import 'package:tatchat/widgets/mxc_image.dart';
import 'package:tatchat/widgets/mxc_image_viewer.dart';
import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import '../../widgets/matrix.dart';
import 'settings_emotes.dart';
enum PopupMenuEmojiActions { import, export }
class EmotesSettingsView extends StatelessWidget {
final EmotesSettingsController controller;
const EmotesSettingsView(this.controller, {super.key});
@override
Widget build(BuildContext context) {
if (controller.widget.roomId != null && controller.room == null) {
return Scaffold(
appBar: AppBar(title: Text(L10n.of(context).oopsSomethingWentWrong)),
body: Center(
child: Text(L10n.of(context).youAreNoLongerParticipatingInThisChat),
),
);
}
final theme = Theme.of(context);
final client = Matrix.of(context).client;
final imageKeys = controller.pack!.images.keys.toList();
final packKeys = controller.packKeys;
if (packKeys != null && packKeys.isEmpty) {
packKeys.add('');
}
final attributionUrl = Uri.tryParse(
controller.packAttributionController.text,
);
return Scaffold(
appBar: AppBar(
leading: controller.showSave
? TextButton(
onPressed: controller.resetAction,
child: Text(L10n.of(context).cancel),
)
: null,
title: Text(L10n.of(context).customEmojisAndStickers),
actions: [
if (controller.showSave)
Padding(
padding: const EdgeInsets.only(right: 16.0),
child: TextButton(
onPressed: () => controller.save(context),
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
),
child: Text(L10n.of(context).saveChanges),
),
)
else
PopupMenuButton<PopupMenuEmojiActions>(
useRootNavigator: true,
onSelected: (value) {
switch (value) {
case PopupMenuEmojiActions.export:
controller.exportAsZip();
break;
case PopupMenuEmojiActions.import:
controller.importEmojiZip();
break;
}
},
itemBuilder: (context) => [
if (!controller.readonly)
PopupMenuItem(
value: PopupMenuEmojiActions.import,
child: Text(L10n.of(context).importFromZipFile),
),
if (imageKeys.isNotEmpty)
PopupMenuItem(
value: PopupMenuEmojiActions.export,
child: Text(L10n.of(context).exportEmotePack),
),
],
),
],
bottom: packKeys == null
? null
: PreferredSize(
preferredSize: const Size.fromHeight(48),
child: Padding(
padding: const EdgeInsets.all(4.0),
child: SizedBox(
height: 40,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: packKeys.length + 1,
itemBuilder: (context, i) {
if (i == 0) {
if (controller.readonly) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4.0,
),
child: FilterChip(
label: const Icon(Icons.add_outlined, size: 20),
onSelected: controller.showSave
? null
: (_) => controller.createImagePack(),
),
);
}
i--;
final key = packKeys[i];
final event = controller.room?.getState(
'im.ponies.room_emotes',
packKeys[i],
);
final eventPack = event?.content
.tryGetMap<String, Object?>('pack');
final packName =
eventPack?.tryGet<String>('display_name') ??
eventPack?.tryGet<String>('name') ??
(key.isNotEmpty ? key : 'Default');
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: FilterChip(
label: Text(packName),
selected:
controller.stateKey == key ||
(controller.stateKey == null && key.isEmpty),
onSelected: controller.showSave
? null
: (_) => controller.setStateKey(key),
),
);
},
),
),
),
),
),
body: MaxWidthBody(
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: <Widget>[
if (controller.room != null) ...[
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TextField(
maxLength: 256,
controller: controller.packDisplayNameController,
readOnly: controller.readonly,
onSubmitted: (_) => controller.submitDisplaynameAction(),
decoration: InputDecoration(
counter: const SizedBox.shrink(),
hintText: controller.stateKey,
labelText: L10n.of(context).stickerPackName,
),
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TextField(
maxLength: 256,
controller: controller.packAttributionController,
readOnly: controller.readonly,
keyboardType: TextInputType.url,
onSubmitted: (_) => controller.submitAttributionAction(),
decoration: InputDecoration(
counter: const SizedBox.shrink(),
labelText: L10n.of(context).attribution,
suffixIcon: attributionUrl == null
? null
: IconButton(
icon: const Icon(Icons.link_outlined),
onPressed: () => UrlLauncher(
context,
attributionUrl.toString(),
).launchUrl(),
),
),
),
),
],
if (!controller.readonly) ...[
Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton.icon(
onPressed: controller.createStickers,
icon: const Icon(Icons.upload_outlined),
label: Text(L10n.of(context).createSticker),
),
),
const Divider(),
],
if (controller.room != null && imageKeys.isNotEmpty)
SwitchListTile.adaptive(
title: Text(L10n.of(context).enableEmotesGlobally),
value: controller.isGloballyActive(client),
onChanged: controller.setIsGloballyActive,
),
imageKeys.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
L10n.of(context).noEmotesFound,
style: const TextStyle(fontSize: 20),
),
),
)
: ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
separatorBuilder: (BuildContext context, int i) =>
const SizedBox.shrink(),
itemCount: imageKeys.length,
itemBuilder: (BuildContext context, int i) {
final imageCode = imageKeys[i];
final image = controller.pack!.images[imageCode]!;
// TODO: Dispose this
final textEditingController = TextEditingController();
textEditingController.text = imageCode;
final useShortCuts =
(PlatformInfos.isWeb || PlatformInfos.isDesktop);
return ListTile(
title: Row(
children: [
Expanded(
child: Shortcuts(
shortcuts: !useShortCuts
? {}
: {
LogicalKeySet(LogicalKeyboardKey.enter):
SubmitLineIntent(),
},
child: Actions(
actions: !useShortCuts
? {}
: {
SubmitLineIntent: CallbackAction(
onInvoke: (i) {
controller.submitImageAction(
imageCode,
image,
textEditingController,
);
return null;
},
),
},
child: TextField(
readOnly: controller.readonly,
controller: textEditingController,
autocorrect: false,
minLines: 1,
maxLines: 1,
maxLength: 128,
decoration: InputDecoration(
hintText: L10n.of(context).emoteShortcode,
prefixText: ': ',
suffixText: ':',
counter: const SizedBox.shrink(),
filled: false,
enabledBorder: const OutlineInputBorder(
borderSide: BorderSide(
color: Colors.transparent,
),
),
),
onSubmitted: (s) =>
controller.submitImageAction(
imageCode,
image,
textEditingController,
),
),
),
),
),
if (!controller.readonly)
PopupMenuButton<ImagePackUsage>(
onSelected: (usage) =>
controller.toggleUsage(imageCode, usage),
itemBuilder: (context) => [
PopupMenuItem(
value: ImagePackUsage.sticker,
child: Row(
mainAxisSize: .min,
children: [
if (image.usage?.contains(
ImagePackUsage.sticker,
) ??
true)
const Icon(Icons.check_outlined),
const SizedBox(width: 12),
Text(L10n.of(context).useAsSticker),
],
),
),
PopupMenuItem(
value: ImagePackUsage.emoticon,
child: Row(
mainAxisSize: .min,
children: [
if (image.usage?.contains(
ImagePackUsage.emoticon,
) ??
true)
const Icon(Icons.check_outlined),
const SizedBox(width: 12),
Text(L10n.of(context).useAsEmoji),
],
),
),
],
icon: const Icon(Icons.edit_outlined),
),
],
),
leading: _EmoteImage(image.url),
trailing: controller.readonly
? null
: IconButton(
tooltip: L10n.of(context).delete,
onPressed: () =>
controller.removeImageAction(imageCode),
icon: const Icon(Icons.delete_outlined),
),
);
},
),
],
),
),
);
}
}
class _EmoteImage extends StatelessWidget {
final Uri mxc;
const _EmoteImage(this.mxc);
@override
Widget build(BuildContext context) {
const size = 44.0;
final key = 'sticker_preview_$mxc';
return InkWell(
borderRadius: BorderRadius.circular(4),
onTap: () =>
showDialog(context: context, builder: (_) => MxcImageViewer(mxc)),
child: MxcImage(
key: ValueKey(key),
cacheKey: key,
uri: mxc,
fit: BoxFit.contain,
width: size,
height: size,
isThumbnail: true,
animated: true,
),
);
}
}
class SubmitLineIntent extends Intent {}