diff --git a/lib/config/routes.dart b/lib/config/routes.dart index 18daa90d..98e3bcd5 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -3,6 +3,7 @@ import 'package:famedlysdk/famedlysdk.dart'; import 'package:fluffychat/views/archive.dart'; import 'package:fluffychat/views/homeserver_picker.dart'; import 'package:fluffychat/views/invitation_selection.dart'; +import 'package:fluffychat/views/settings_emotes.dart'; import 'package:fluffychat/views/sign_up.dart'; import 'package:fluffychat/views/sign_up_password.dart'; import 'package:fluffychat/views/widgets/matrix.dart'; @@ -21,7 +22,6 @@ import 'package:fluffychat/views/search.dart'; import 'package:fluffychat/views/ui/settings_ui.dart'; import 'package:fluffychat/views/settings_3pid.dart'; import 'package:fluffychat/views/device_settings.dart'; -import 'package:fluffychat/views/ui/settings_emotes_ui.dart'; import 'package:fluffychat/views/ui/settings_ignore_list_ui.dart'; import 'package:fluffychat/views/ui/settings_multiple_emotes_ui.dart'; import 'package:fluffychat/views/ui/settings_notifications_ui.dart'; diff --git a/lib/views/settings_emotes.dart b/lib/views/settings_emotes.dart new file mode 100644 index 00000000..6e8692ce --- /dev/null +++ b/lib/views/settings_emotes.dart @@ -0,0 +1,299 @@ +import 'package:adaptive_dialog/adaptive_dialog.dart'; +import 'package:adaptive_page_layout/adaptive_page_layout.dart'; +import 'package:famedlysdk/famedlysdk.dart'; +import 'package:file_picker_cross/file_picker_cross.dart'; +import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +import 'package:future_loading_dialog/future_loading_dialog.dart'; +import 'package:image_picker/image_picker.dart'; +import 'ui/settings_emotes_ui.dart'; +import 'widgets/matrix.dart'; + +class EmotesSettings extends StatefulWidget { + final Room room; + final String stateKey; + + EmotesSettings({this.room, this.stateKey}); + + @override + EmotesSettingsController createState() => EmotesSettingsController(); +} + +class EmoteEntry { + String emote; + String mxc; + EmoteEntry({this.emote, this.mxc}); + + String get emoteClean => emote.substring(1, emote.length - 1); +} + +class EmotesSettingsController extends State { + List emotes; + bool showSave = false; + TextEditingController newEmoteController = TextEditingController(); + TextEditingController newMxcController = TextEditingController(); + + Future _save(BuildContext context) async { + if (readonly) { + return; + } + final client = Matrix.of(context).client; + // be sure to preserve any data not in "short" + Map content; + if (widget.room != null) { + content = widget.room + .getState('im.ponies.room_emotes', widget.stateKey ?? '') + ?.content ?? + {}; + } else { + content = client.accountData['im.ponies.user_emotes']?.content ?? + {}; + } + if (!(content['emoticons'] is Map)) { + content['emoticons'] = {}; + } + // add / update changed emotes + final allowedShortcodes = {}; + for (final emote in emotes) { + allowedShortcodes.add(emote.emote); + if (!(content['emoticons'][emote.emote] is Map)) { + content['emoticons'][emote.emote] = {}; + } + content['emoticons'][emote.emote]['url'] = emote.mxc; + } + // remove emotes no more needed + // we make the iterator .toList() here so that we don't get into trouble modifying the very + // thing we are iterating over + for (final shortcode in content['emoticons'].keys.toList()) { + if (!allowedShortcodes.contains(shortcode)) { + content['emoticons'].remove(shortcode); + } + } + // remove the old "short" key + content.remove('short'); + if (widget.room != null) { + await showFutureLoadingDialog( + context: context, + future: () => client.sendState(widget.room.id, 'im.ponies.room_emotes', + content, widget.stateKey ?? ''), + ); + } else { + await showFutureLoadingDialog( + context: context, + future: () => client.setAccountData( + client.userID, 'im.ponies.user_emotes', content), + ); + } + } + + Future setIsGloballyActive(bool active) async { + if (widget.room == null) { + return; + } + final client = Matrix.of(context).client; + final content = client.accountData['im.ponies.emote_rooms']?.content ?? + {}; + if (active) { + if (!(content['rooms'] is Map)) { + content['rooms'] = {}; + } + if (!(content['rooms'][widget.room.id] is Map)) { + content['rooms'][widget.room.id] = {}; + } + if (!(content['rooms'][widget.room.id][widget.stateKey ?? ''] is Map)) { + content['rooms'][widget.room.id] + [widget.stateKey ?? ''] = {}; + } + } else if (content['rooms'] is Map && + content['rooms'][widget.room.id] is Map) { + content['rooms'][widget.room.id].remove(widget.stateKey ?? ''); + } + // and save + await showFutureLoadingDialog( + context: context, + future: () => client.setAccountData( + client.userID, 'im.ponies.emote_rooms', content), + ); + setState(() => null); + } + + void removeEmoteAction(EmoteEntry emote) => setState(() { + emotes.removeWhere((e) => e.emote == emote.emote); + showSave = true; + }); + + void submitEmoteAction( + String s, + EmoteEntry emote, + TextEditingController controller, + ) { + final emoteCode = ':$s:'; + if (emotes.indexWhere((e) => e.emote == emoteCode && e.mxc != emote.mxc) != + -1) { + controller.text = emote.emoteClean; + showOkAlertDialog( + context: context, + message: L10n.of(context).emoteExists, + okLabel: L10n.of(context).ok, + useRootNavigator: false, + ); + return; + } + if (!RegExp(r'^:[-\w]+:$').hasMatch(emoteCode)) { + controller.text = emote.emoteClean; + showOkAlertDialog( + context: context, + message: L10n.of(context).emoteInvalid, + okLabel: L10n.of(context).ok, + useRootNavigator: false, + ); + return; + } + setState(() { + emote.emote = emoteCode; + showSave = true; + }); + } + + bool isGloballyActive(Client client) => + widget.room != null && + client.accountData['im.ponies.emote_rooms']?.content is Map && + client.accountData['im.ponies.emote_rooms'].content['rooms'] is Map && + client.accountData['im.ponies.emote_rooms'].content['rooms'] + [widget.room.id] is Map && + client.accountData['im.ponies.emote_rooms'].content['rooms'] + [widget.room.id][widget.stateKey ?? ''] is Map; + + bool get readonly => widget.room == null + ? false + : !(widget.room.canSendEvent('im.ponies.room_emotes')); + + void saveAction() async { + await _save(context); + setState(() { + showSave = false; + }); + } + + void addEmoteAction() async { + if (newEmoteController.text == null || + newEmoteController.text.isEmpty || + newMxcController.text == null || + newMxcController.text.isEmpty) { + await showOkAlertDialog( + context: context, + message: L10n.of(context).emoteWarnNeedToPick, + okLabel: L10n.of(context).ok, + useRootNavigator: false, + ); + return; + } + final emoteCode = ':${newEmoteController.text}:'; + final mxc = newMxcController.text; + if (emotes.indexWhere((e) => e.emote == emoteCode && e.mxc != mxc) != -1) { + await showOkAlertDialog( + context: context, + message: L10n.of(context).emoteExists, + okLabel: L10n.of(context).ok, + useRootNavigator: false, + ); + return; + } + if (!RegExp(r'^:[-\w]+:$').hasMatch(emoteCode)) { + await showOkAlertDialog( + context: context, + message: L10n.of(context).emoteInvalid, + okLabel: L10n.of(context).ok, + useRootNavigator: false, + ); + return; + } + emotes.add(EmoteEntry(emote: emoteCode, mxc: mxc)); + await _save(context); + setState(() { + newEmoteController.text = ''; + newMxcController.text = ''; + showSave = false; + }); + } + + void emoteImagePickerAction(TextEditingController controller) async { + if (kIsWeb) { + AdaptivePageLayout.of(context).showSnackBar( + SnackBar(content: Text(L10n.of(context).notSupportedInWeb))); + return; + } + MatrixFile file; + if (PlatformInfos.isMobile) { + final result = await ImagePicker().getImage( + source: ImageSource.gallery, + imageQuality: 50, + maxWidth: 1600, + maxHeight: 1600); + if (result == null) return; + file = MatrixFile( + bytes: await result.readAsBytes(), + name: result.path, + ); + } else { + final result = + await FilePickerCross.importFromStorage(type: FileTypeCross.image); + if (result == null) return; + file = MatrixFile( + bytes: result.toUint8List(), + name: result.fileName, + ); + } + final uploadResp = await showFutureLoadingDialog( + context: context, + future: () => Matrix.of(context).client.upload(file.bytes, file.name), + ); + if (uploadResp.error == null) { + setState(() { + controller.text = uploadResp.result; + }); + } + } + + @override + Widget build(BuildContext context) { + if (emotes == null) { + emotes = []; + Map emoteSource; + if (widget.room != null) { + emoteSource = widget.room + .getState('im.ponies.room_emotes', widget.stateKey ?? '') + ?.content; + } else { + emoteSource = Matrix.of(context) + .client + .accountData['im.ponies.user_emotes'] + ?.content; + } + if (emoteSource != null) { + if (emoteSource['emoticons'] is Map) { + emoteSource['emoticons'].forEach((key, value) { + if (key is String && + value is Map && + value['url'] is String && + value['url'].startsWith('mxc://')) { + emotes.add(EmoteEntry(emote: key, mxc: value['url'])); + } + }); + } else if (emoteSource['short'] is Map) { + emoteSource['short'].forEach((key, value) { + if (key is String && + value is String && + value.startsWith('mxc://')) { + emotes.add(EmoteEntry(emote: key, mxc: value)); + } + }); + } + } + } + return EmotesSettingsUI(this); + } +} diff --git a/lib/views/ui/settings_emotes_ui.dart b/lib/views/ui/settings_emotes_ui.dart index 1e5fe1e6..18ae8b5e 100644 --- a/lib/views/ui/settings_emotes_ui.dart +++ b/lib/views/ui/settings_emotes_ui.dart @@ -1,197 +1,38 @@ -import 'package:adaptive_dialog/adaptive_dialog.dart'; -import 'package:adaptive_page_layout/adaptive_page_layout.dart'; - import 'package:cached_network_image/cached_network_image.dart'; import 'package:famedlysdk/famedlysdk.dart'; -import 'package:file_picker_cross/file_picker_cross.dart'; - -import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/views/widgets/max_width_body.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:image_picker/image_picker.dart'; - -import 'package:future_loading_dialog/future_loading_dialog.dart'; import '../widgets/matrix.dart'; +import '../settings_emotes.dart'; -class EmotesSettings extends StatefulWidget { - final Room room; - final String stateKey; +class EmotesSettingsUI extends StatelessWidget { + final EmotesSettingsController controller; - EmotesSettings({this.room, this.stateKey}); - - @override - _EmotesSettingsState createState() => _EmotesSettingsState(); -} - -class _EmoteEntry { - String emote; - String mxc; - _EmoteEntry({this.emote, this.mxc}); - - String get emoteClean => emote.substring(1, emote.length - 1); -} - -class _EmotesSettingsState extends State { - List<_EmoteEntry> emotes; - bool showSave = false; - TextEditingController newEmoteController = TextEditingController(); - TextEditingController newMxcController = TextEditingController(); - - Future _save(BuildContext context) async { - if (readonly) { - return; - } - final client = Matrix.of(context).client; - // be sure to preserve any data not in "short" - Map content; - if (widget.room != null) { - content = widget.room - .getState('im.ponies.room_emotes', widget.stateKey ?? '') - ?.content ?? - {}; - } else { - content = client.accountData['im.ponies.user_emotes']?.content ?? - {}; - } - if (!(content['emoticons'] is Map)) { - content['emoticons'] = {}; - } - // add / update changed emotes - final allowedShortcodes = {}; - for (final emote in emotes) { - allowedShortcodes.add(emote.emote); - if (!(content['emoticons'][emote.emote] is Map)) { - content['emoticons'][emote.emote] = {}; - } - content['emoticons'][emote.emote]['url'] = emote.mxc; - } - // remove emotes no more needed - // we make the iterator .toList() here so that we don't get into trouble modifying the very - // thing we are iterating over - for (final shortcode in content['emoticons'].keys.toList()) { - if (!allowedShortcodes.contains(shortcode)) { - content['emoticons'].remove(shortcode); - } - } - // remove the old "short" key - content.remove('short'); - if (widget.room != null) { - await showFutureLoadingDialog( - context: context, - future: () => client.sendState(widget.room.id, 'im.ponies.room_emotes', - content, widget.stateKey ?? ''), - ); - } else { - await showFutureLoadingDialog( - context: context, - future: () => client.setAccountData( - client.userID, 'im.ponies.user_emotes', content), - ); - } - } - - Future _setIsGloballyActive(BuildContext context, bool active) async { - if (widget.room == null) { - return; - } - final client = Matrix.of(context).client; - final content = client.accountData['im.ponies.emote_rooms']?.content ?? - {}; - if (active) { - if (!(content['rooms'] is Map)) { - content['rooms'] = {}; - } - if (!(content['rooms'][widget.room.id] is Map)) { - content['rooms'][widget.room.id] = {}; - } - if (!(content['rooms'][widget.room.id][widget.stateKey ?? ''] is Map)) { - content['rooms'][widget.room.id] - [widget.stateKey ?? ''] = {}; - } - } else if (content['rooms'] is Map && - content['rooms'][widget.room.id] is Map) { - content['rooms'][widget.room.id].remove(widget.stateKey ?? ''); - } - // and save - await showFutureLoadingDialog( - context: context, - future: () => client.setAccountData( - client.userID, 'im.ponies.emote_rooms', content), - ); - } - - bool isGloballyActive(Client client) => - widget.room != null && - client.accountData['im.ponies.emote_rooms']?.content is Map && - client.accountData['im.ponies.emote_rooms'].content['rooms'] is Map && - client.accountData['im.ponies.emote_rooms'].content['rooms'] - [widget.room.id] is Map && - client.accountData['im.ponies.emote_rooms'].content['rooms'] - [widget.room.id][widget.stateKey ?? ''] is Map; - - bool get readonly => widget.room == null - ? false - : !(widget.room.canSendEvent('im.ponies.room_emotes')); + const EmotesSettingsUI(this.controller, {Key key}) : super(key: key); @override Widget build(BuildContext context) { final client = Matrix.of(context).client; - if (emotes == null) { - emotes = <_EmoteEntry>[]; - Map emoteSource; - if (widget.room != null) { - emoteSource = widget.room - .getState('im.ponies.room_emotes', widget.stateKey ?? '') - ?.content; - } else { - emoteSource = client.accountData['im.ponies.user_emotes']?.content; - } - if (emoteSource != null) { - if (emoteSource['emoticons'] is Map) { - emoteSource['emoticons'].forEach((key, value) { - if (key is String && - value is Map && - value['url'] is String && - value['url'].startsWith('mxc://')) { - emotes.add(_EmoteEntry(emote: key, mxc: value['url'])); - } - }); - } else if (emoteSource['short'] is Map) { - emoteSource['short'].forEach((key, value) { - if (key is String && - value is String && - value.startsWith('mxc://')) { - emotes.add(_EmoteEntry(emote: key, mxc: value)); - } - }); - } - } - } return Scaffold( appBar: AppBar( leading: BackButton(), title: Text(L10n.of(context).emoteSettings), ), - floatingActionButton: showSave + floatingActionButton: controller.showSave ? FloatingActionButton( - onPressed: () async { - await _save(context); - setState(() { - showSave = false; - }); - }, + onPressed: controller.saveAction, child: Icon(Icons.save_outlined, color: Colors.white), ) : null, body: MaxWidthBody( child: StreamBuilder( - stream: widget.room?.onUpdate?.stream, + stream: controller.widget.room?.onUpdate?.stream, builder: (context, snapshot) { return Column( children: [ - if (!readonly) + if (!controller.readonly) Container( padding: EdgeInsets.symmetric( vertical: 8.0, @@ -206,7 +47,7 @@ class _EmotesSettingsState extends State { color: Theme.of(context).secondaryHeaderColor, ), child: TextField( - controller: newEmoteController, + controller: controller.newEmoteController, autocorrect: false, minLines: 1, maxLines: 1, @@ -226,51 +67,12 @@ class _EmotesSettingsState extends State { ), ), ), - title: _EmoteImagePicker(newMxcController), + title: _EmoteImagePicker( + controller: controller.newMxcController, + onPressed: controller.emoteImagePickerAction, + ), trailing: InkWell( - onTap: () async { - if (newEmoteController.text == null || - newEmoteController.text.isEmpty || - newMxcController.text == null || - newMxcController.text.isEmpty) { - await showOkAlertDialog( - context: context, - message: L10n.of(context).emoteWarnNeedToPick, - okLabel: L10n.of(context).ok, - useRootNavigator: false, - ); - return; - } - final emoteCode = ':${newEmoteController.text}:'; - final mxc = newMxcController.text; - if (emotes.indexWhere((e) => - e.emote == emoteCode && e.mxc != mxc) != - -1) { - await showOkAlertDialog( - context: context, - message: L10n.of(context).emoteExists, - okLabel: L10n.of(context).ok, - useRootNavigator: false, - ); - return; - } - if (!RegExp(r'^:[-\w]+:$').hasMatch(emoteCode)) { - await showOkAlertDialog( - context: context, - message: L10n.of(context).emoteInvalid, - okLabel: L10n.of(context).ok, - useRootNavigator: false, - ); - return; - } - emotes.add(_EmoteEntry(emote: emoteCode, mxc: mxc)); - await _save(context); - setState(() { - newEmoteController.text = ''; - newMxcController.text = ''; - showSave = false; - }); - }, + onTap: controller.addEmoteAction, child: Icon( Icons.add_outlined, color: Colors.green, @@ -279,25 +81,22 @@ class _EmotesSettingsState extends State { ), ), ), - if (widget.room != null) + if (controller.widget.room != null) ListTile( title: Text(L10n.of(context).enableEmotesGlobally), trailing: Switch( - value: isGloballyActive(client), - onChanged: (bool newValue) async { - await _setIsGloballyActive(context, newValue); - setState(() => null); - }, + value: controller.isGloballyActive(client), + onChanged: controller.setIsGloballyActive, ), ), - if (!readonly || widget.room != null) + if (!controller.readonly || controller.widget.room != null) Divider( height: 2, thickness: 2, color: Theme.of(context).primaryColor, ), Expanded( - child: emotes.isEmpty + child: controller.emotes.isEmpty ? Center( child: Padding( padding: EdgeInsets.all(16), @@ -310,14 +109,15 @@ class _EmotesSettingsState extends State { : ListView.separated( separatorBuilder: (BuildContext context, int i) => Container(), - itemCount: emotes.length + 1, + itemCount: controller.emotes.length + 1, itemBuilder: (BuildContext context, int i) { - if (i >= emotes.length) { + if (i >= controller.emotes.length) { return Container(height: 70); } - final emote = emotes[i]; - final controller = TextEditingController(); - controller.text = emote.emoteClean; + final emote = controller.emotes[i]; + final textEditingController = + TextEditingController(); + textEditingController.text = emote.emoteClean; return ListTile( leading: Container( width: 180.0, @@ -330,8 +130,8 @@ class _EmotesSettingsState extends State { Theme.of(context).secondaryHeaderColor, ), child: TextField( - readOnly: readonly, - controller: controller, + readOnly: controller.readonly, + controller: textEditingController, autocorrect: false, minLines: 1, maxLines: 1, @@ -349,49 +149,20 @@ class _EmotesSettingsState extends State { ), border: InputBorder.none, ), - onSubmitted: (s) { - final emoteCode = ':$s:'; - if (emotes.indexWhere((e) => - e.emote == emoteCode && - e.mxc != emote.mxc) != - -1) { - controller.text = emote.emoteClean; - showOkAlertDialog( - context: context, - message: L10n.of(context).emoteExists, - okLabel: L10n.of(context).ok, - useRootNavigator: false, - ); - return; - } - if (!RegExp(r'^:[-\w]+:$') - .hasMatch(emoteCode)) { - controller.text = emote.emoteClean; - showOkAlertDialog( - context: context, - message: - L10n.of(context).emoteInvalid, - okLabel: L10n.of(context).ok, - useRootNavigator: false, - ); - return; - } - setState(() { - emote.emote = emoteCode; - showSave = true; - }); - }, + onSubmitted: (s) => + controller.submitEmoteAction( + s, + emote, + textEditingController, + ), ), ), title: _EmoteImage(emote.mxc), - trailing: readonly + trailing: controller.readonly ? null : InkWell( - onTap: () => setState(() { - emotes.removeWhere( - (e) => e.emote == emote.emote); - showSave = true; - }), + onTap: () => + controller.removeEmoteAction(emote), child: Icon( Icons.delete_forever_outlined, color: Colors.red, @@ -436,7 +207,9 @@ class _EmoteImage extends StatelessWidget { class _EmoteImagePicker extends StatefulWidget { final TextEditingController controller; - _EmoteImagePicker(this.controller); + final void Function(TextEditingController) onPressed; + + _EmoteImagePicker({@required this.controller, @required this.onPressed}); @override _EmoteImagePickerState createState() => _EmoteImagePickerState(); @@ -447,44 +220,7 @@ class _EmoteImagePickerState extends State<_EmoteImagePicker> { Widget build(BuildContext context) { if (widget.controller.text == null || widget.controller.text.isEmpty) { return ElevatedButton( - onPressed: () async { - if (kIsWeb) { - AdaptivePageLayout.of(context).showSnackBar( - SnackBar(content: Text(L10n.of(context).notSupportedInWeb))); - return; - } - MatrixFile file; - if (PlatformInfos.isMobile) { - final result = await ImagePicker().getImage( - source: ImageSource.gallery, - imageQuality: 50, - maxWidth: 1600, - maxHeight: 1600); - if (result == null) return; - file = MatrixFile( - bytes: await result.readAsBytes(), - name: result.path, - ); - } else { - final result = await FilePickerCross.importFromStorage( - type: FileTypeCross.image); - if (result == null) return; - file = MatrixFile( - bytes: result.toUint8List(), - name: result.fileName, - ); - } - final uploadResp = await showFutureLoadingDialog( - context: context, - future: () => - Matrix.of(context).client.upload(file.bytes, file.name), - ); - if (uploadResp.error == null) { - setState(() { - widget.controller.text = uploadResp.result; - }); - } - }, + onPressed: () async {}, child: Text(L10n.of(context).pickImage), ); } else {