From 4337db58bea614b36fde3431ff4ec88382da593f Mon Sep 17 00:00:00 2001 From: otsmr Date: Mon, 5 Oct 2026 21:15:22 +0200 Subject: [PATCH] fix smaller layout issues and add sticker creation button to stored images --- .../generated/app_localizations.dart | 60 ++++++ .../generated/app_localizations_ar.dart | 38 ++++ .../generated/app_localizations_de.dart | 38 ++++ .../generated/app_localizations_en.dart | 37 ++++ lib/src/localization/translations/ar.arb | 10 + lib/src/localization/translations/de.arb | 10 + lib/src/localization/translations/en.arb | 10 + .../services/stickers/sticker.service.dart | 43 +++- lib/src/visual/components/sticker_picker.dart | 184 ++++++++++-------- .../views/chats/chat_messages.view.dart | 8 +- .../message_input.dart | 15 +- .../contact/contact_group_settings.view.dart | 147 +++++++++++--- .../group_create_select_group_name.view.dart | 15 +- .../group_create_select_members.view.dart | 16 +- ...chronized_viewer_actions_toolbar.comp.dart | 27 +++ .../memories/sticker_source_picker.view.dart | 136 +++++++++++++ .../memories/synchronized_viewer.view.dart | 27 +++ .../components/sticker_picker_test.dart | 67 ++++++- .../memories/sticker_viewer_action_test.dart | 53 +++++ 19 files changed, 798 insertions(+), 143 deletions(-) create mode 100644 lib/src/visual/views/memories/sticker_source_picker.view.dart create mode 100644 test/visual/memories/sticker_viewer_action_test.dart diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 26c2f64e..b04f82fb 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -1906,6 +1906,12 @@ abstract class AppLocalizations { /// **'Group name'** String get groupNameInput; + /// No description provided for @contactGroupNamePlaceholder. + /// + /// In en, this message translates to: + /// **'Enter name here'** + String get contactGroupNamePlaceholder; + /// No description provided for @groupMembers. /// /// In en, this message translates to: @@ -4774,6 +4780,54 @@ abstract class AppLocalizations { /// **'Home screen widget'** String get contactGroupUsedByWidget; + /// No description provided for @contactGroupAddWidgetSubtitle. + /// + /// In en, this message translates to: + /// **'Add a widget for this contact group.'** + String get contactGroupAddWidgetSubtitle; + + /// No description provided for @contactGroupWidgetTutorialTitle. + /// + /// In en, this message translates to: + /// **'Add a home screen widget'** + String get contactGroupWidgetTutorialTitle; + + /// No description provided for @contactGroupWidgetTutorialAndroidStep1. + /// + /// In en, this message translates to: + /// **'Touch and hold an empty area on your home screen, then tap Widgets.'** + String get contactGroupWidgetTutorialAndroidStep1; + + /// No description provided for @contactGroupWidgetTutorialAndroidStep2. + /// + /// In en, this message translates to: + /// **'Find twonly, then drag its widget onto your home screen.'** + String get contactGroupWidgetTutorialAndroidStep2; + + /// No description provided for @contactGroupWidgetTutorialAndroidStep3. + /// + /// In en, this message translates to: + /// **'Select this contact group in the setup screen, then tap Save.'** + String get contactGroupWidgetTutorialAndroidStep3; + + /// No description provided for @contactGroupWidgetTutorialIosStep1. + /// + /// In en, this message translates to: + /// **'Touch and hold an empty area on your Home Screen, then tap Edit and Add Widget.'** + String get contactGroupWidgetTutorialIosStep1; + + /// No description provided for @contactGroupWidgetTutorialIosStep2. + /// + /// In en, this message translates to: + /// **'Find twonly, choose a size, then tap Add Widget.'** + String get contactGroupWidgetTutorialIosStep2; + + /// No description provided for @contactGroupWidgetTutorialIosStep3. + /// + /// In en, this message translates to: + /// **'Touch and hold the widget, tap Edit Widget, then select this contact group.'** + String get contactGroupWidgetTutorialIosStep3; + /// No description provided for @contactGroupUsedByWidgetSubtitle. /// /// In en, this message translates to: @@ -5014,6 +5068,12 @@ abstract class AppLocalizations { /// **'Create sticker'** String get createSticker; + /// No description provided for @stickerPickFromGallery. + /// + /// In en, this message translates to: + /// **'Gallery'** + String get stickerPickFromGallery; + /// No description provided for @noStickersYet. /// /// In en, this message translates to: diff --git a/lib/src/localization/generated/app_localizations_ar.dart b/lib/src/localization/generated/app_localizations_ar.dart index 013e91b5..96948475 100644 --- a/lib/src/localization/generated/app_localizations_ar.dart +++ b/lib/src/localization/generated/app_localizations_ar.dart @@ -1033,6 +1033,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get groupNameInput => 'اسم المجموعة'; + @override + String get contactGroupNamePlaceholder => 'أدخل الاسم هنا'; + @override String get groupMembers => 'الأعضاء'; @@ -2779,6 +2782,38 @@ class AppLocalizationsAr extends AppLocalizations { @override String get contactGroupUsedByWidget => 'أداة الشاشة الرئيسية'; + @override + String get contactGroupAddWidgetSubtitle => + 'أضف أداة لمجموعة جهات الاتصال هذه.'; + + @override + String get contactGroupWidgetTutorialTitle => + 'إضافة أداة إلى الشاشة الرئيسية'; + + @override + String get contactGroupWidgetTutorialAndroidStep1 => + 'المس مطولاً مساحة فارغة على شاشتك الرئيسية، ثم اضغط على «الأدوات».'; + + @override + String get contactGroupWidgetTutorialAndroidStep2 => + 'ابحث عن twonly، ثم اسحب أداته إلى شاشتك الرئيسية.'; + + @override + String get contactGroupWidgetTutorialAndroidStep3 => + 'اختر مجموعة جهات الاتصال هذه في شاشة الإعداد، ثم اضغط على «حفظ».'; + + @override + String get contactGroupWidgetTutorialIosStep1 => + 'المس مطولاً مساحة فارغة على شاشتك الرئيسية، ثم اضغط على «تحرير» و«إضافة أداة».'; + + @override + String get contactGroupWidgetTutorialIosStep2 => + 'ابحث عن twonly، واختر حجماً، ثم اضغط على «إضافة أداة».'; + + @override + String get contactGroupWidgetTutorialIosStep3 => + 'المس الأداة مطولاً، واضغط على «تحرير الأداة»، ثم اختر مجموعة جهات الاتصال هذه.'; + @override String contactGroupUsedByWidgetSubtitle(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2954,6 +2989,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get createSticker => 'إنشاء ملصق'; + @override + String get stickerPickFromGallery => 'المعرض'; + @override String get noStickersYet => 'أنشئ ملصقك الأول من صورة.'; diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index 58b3bc05..8e08eb4d 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -1028,6 +1028,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get groupNameInput => 'Gruppennamen'; + @override + String get contactGroupNamePlaceholder => 'Namen hier eingeben'; + @override String get groupMembers => 'Mitglieder'; @@ -2785,6 +2788,38 @@ class AppLocalizationsDe extends AppLocalizations { @override String get contactGroupUsedByWidget => 'Homebildschirm-Widget'; + @override + String get contactGroupAddWidgetSubtitle => + 'Füge ein Widget für diese Kontaktgruppe hinzu.'; + + @override + String get contactGroupWidgetTutorialTitle => + 'Homebildschirm-Widget hinzufügen'; + + @override + String get contactGroupWidgetTutorialAndroidStep1 => + 'Halte eine freie Stelle auf deinem Homebildschirm gedrückt und tippe dann auf „Widgets“.'; + + @override + String get contactGroupWidgetTutorialAndroidStep2 => + 'Suche nach twonly und ziehe das Widget auf deinen Homebildschirm.'; + + @override + String get contactGroupWidgetTutorialAndroidStep3 => + 'Wähle diese Kontaktgruppe in der Einrichtung aus und tippe dann auf „Speichern“.'; + + @override + String get contactGroupWidgetTutorialIosStep1 => + 'Halte eine freie Stelle auf deinem Homebildschirm gedrückt und tippe dann auf „Bearbeiten“ und „Widget hinzufügen“.'; + + @override + String get contactGroupWidgetTutorialIosStep2 => + 'Suche nach twonly, wähle eine Größe und tippe dann auf „Widget hinzufügen“.'; + + @override + String get contactGroupWidgetTutorialIosStep3 => + 'Halte das Widget gedrückt, tippe auf „Widget bearbeiten“ und wähle dann diese Kontaktgruppe aus.'; + @override String contactGroupUsedByWidgetSubtitle(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2955,6 +2990,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get createSticker => 'Sticker erstellen'; + @override + String get stickerPickFromGallery => 'Galerie'; + @override String get noStickersYet => 'Erstelle deinen ersten Sticker aus einem Foto.'; diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index cc8dfae6..7cc49fa0 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -1022,6 +1022,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get groupNameInput => 'Group name'; + @override + String get contactGroupNamePlaceholder => 'Enter name here'; + @override String get groupMembers => 'Members'; @@ -2760,6 +2763,37 @@ class AppLocalizationsEn extends AppLocalizations { @override String get contactGroupUsedByWidget => 'Home screen widget'; + @override + String get contactGroupAddWidgetSubtitle => + 'Add a widget for this contact group.'; + + @override + String get contactGroupWidgetTutorialTitle => 'Add a home screen widget'; + + @override + String get contactGroupWidgetTutorialAndroidStep1 => + 'Touch and hold an empty area on your home screen, then tap Widgets.'; + + @override + String get contactGroupWidgetTutorialAndroidStep2 => + 'Find twonly, then drag its widget onto your home screen.'; + + @override + String get contactGroupWidgetTutorialAndroidStep3 => + 'Select this contact group in the setup screen, then tap Save.'; + + @override + String get contactGroupWidgetTutorialIosStep1 => + 'Touch and hold an empty area on your Home Screen, then tap Edit and Add Widget.'; + + @override + String get contactGroupWidgetTutorialIosStep2 => + 'Find twonly, choose a size, then tap Add Widget.'; + + @override + String get contactGroupWidgetTutorialIosStep3 => + 'Touch and hold the widget, tap Edit Widget, then select this contact group.'; + @override String contactGroupUsedByWidgetSubtitle(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -2926,6 +2960,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get createSticker => 'Create sticker'; + @override + String get stickerPickFromGallery => 'Gallery'; + @override String get noStickersYet => 'Create your first sticker from a photo.'; diff --git a/lib/src/localization/translations/ar.arb b/lib/src/localization/translations/ar.arb index 056d2b26..3e5d5ca4 100644 --- a/lib/src/localization/translations/ar.arb +++ b/lib/src/localization/translations/ar.arb @@ -301,6 +301,7 @@ "selectMembers": "اختر الأعضاء", "selectGroupName": "اختر اسم المجموعة", "groupNameInput": "اسم المجموعة", + "contactGroupNamePlaceholder": "أدخل الاسم هنا", "groupMembers": "الأعضاء", "addMember": "إضافة عضو", "createGroup": "إنشاء مجموعة", @@ -779,6 +780,14 @@ "fileLimitReachedHintFree": "سجّل فيديو أقصر، أو قم بترقية خطتك لإرسال ملفات أكبر.", "fileLimitReachedUpgrade": "ترقية الخطة", "contactGroupUsedByWidget": "أداة الشاشة الرئيسية", + "contactGroupAddWidgetSubtitle": "أضف أداة لمجموعة جهات الاتصال هذه.", + "contactGroupWidgetTutorialTitle": "إضافة أداة إلى الشاشة الرئيسية", + "contactGroupWidgetTutorialAndroidStep1": "المس مطولاً مساحة فارغة على شاشتك الرئيسية، ثم اضغط على «الأدوات».", + "contactGroupWidgetTutorialAndroidStep2": "ابحث عن twonly، ثم اسحب أداته إلى شاشتك الرئيسية.", + "contactGroupWidgetTutorialAndroidStep3": "اختر مجموعة جهات الاتصال هذه في شاشة الإعداد، ثم اضغط على «حفظ».", + "contactGroupWidgetTutorialIosStep1": "المس مطولاً مساحة فارغة على شاشتك الرئيسية، ثم اضغط على «تحرير» و«إضافة أداة».", + "contactGroupWidgetTutorialIosStep2": "ابحث عن twonly، واختر حجماً، ثم اضغط على «إضافة أداة».", + "contactGroupWidgetTutorialIosStep3": "المس الأداة مطولاً، واضغط على «تحرير الأداة»، ثم اختر مجموعة جهات الاتصال هذه.", "contactGroupUsedByWidgetSubtitle": "{count, plural, =1{تعرض أداة على شاشتك الرئيسية صورًا من هذه المجموعة.} =2{تعرض أداتان على شاشتك الرئيسية صورًا من هذه المجموعة.} few{تعرض {count} أدوات على شاشتك الرئيسية صورًا من هذه المجموعة.} many{تعرض {count} أداةً على شاشتك الرئيسية صورًا من هذه المجموعة.} other{تعرض {count} أداة على شاشتك الرئيسية صورًا من هذه المجموعة.}}", "contactGroupDeleteBlockedByWidget": "{count, plural, =1{تستخدم أداةٌ على شاشتك الرئيسية مجموعة جهات الاتصال هذه. أزل تلك الأداة أولًا، ثم يمكنك حذف المجموعة.} =2{تستخدم أداتان على شاشتك الرئيسية مجموعة جهات الاتصال هذه. أزل هاتين الأداتين أولًا، ثم يمكنك حذف المجموعة.} few{تستخدم {count} أدوات على شاشتك الرئيسية مجموعة جهات الاتصال هذه. أزل تلك الأدوات أولًا، ثم يمكنك حذف المجموعة.} many{تستخدم {count} أداةً على شاشتك الرئيسية مجموعة جهات الاتصال هذه. أزل تلك الأدوات أولًا، ثم يمكنك حذف المجموعة.} other{تستخدم {count} أداة على شاشتك الرئيسية مجموعة جهات الاتصال هذه. أزل تلك الأدوات أولًا، ثم يمكنك حذف المجموعة.}}", "webxdcStoreMenu": "تطبيق", @@ -840,6 +849,7 @@ "sticker": "ملصق", "stickers": "الملصقات", "createSticker": "إنشاء ملصق", + "stickerPickFromGallery": "المعرض", "noStickersYet": "أنشئ ملصقك الأول من صورة.", "stickerAdded": "تم حفظ الملصق", "stickerAlreadySaved": "هذا الملصق محفوظ بالفعل", diff --git a/lib/src/localization/translations/de.arb b/lib/src/localization/translations/de.arb index 38bdac88..58926b19 100644 --- a/lib/src/localization/translations/de.arb +++ b/lib/src/localization/translations/de.arb @@ -363,6 +363,7 @@ "selectMembers": "Mitglieder auswählen", "selectGroupName": "Gruppennamen wählen", "groupNameInput": "Gruppennamen", + "contactGroupNamePlaceholder": "Namen hier eingeben", "groupMembers": "Mitglieder", "createGroup": "Gruppe erstellen", "addMember": "Mitglied hinzufügen", @@ -1078,6 +1079,14 @@ "fileLimitReachedHintFree": "Nimm ein kürzeres Video auf oder wechsle den Tarif, um größere Dateien zu senden.", "fileLimitReachedUpgrade": "Tarif wechseln", "contactGroupUsedByWidget": "Homebildschirm-Widget", + "contactGroupAddWidgetSubtitle": "Füge ein Widget für diese Kontaktgruppe hinzu.", + "contactGroupWidgetTutorialTitle": "Homebildschirm-Widget hinzufügen", + "contactGroupWidgetTutorialAndroidStep1": "Halte eine freie Stelle auf deinem Homebildschirm gedrückt und tippe dann auf „Widgets“.", + "contactGroupWidgetTutorialAndroidStep2": "Suche nach twonly und ziehe das Widget auf deinen Homebildschirm.", + "contactGroupWidgetTutorialAndroidStep3": "Wähle diese Kontaktgruppe in der Einrichtung aus und tippe dann auf „Speichern“.", + "contactGroupWidgetTutorialIosStep1": "Halte eine freie Stelle auf deinem Homebildschirm gedrückt und tippe dann auf „Bearbeiten“ und „Widget hinzufügen“.", + "contactGroupWidgetTutorialIosStep2": "Suche nach twonly, wähle eine Größe und tippe dann auf „Widget hinzufügen“.", + "contactGroupWidgetTutorialIosStep3": "Halte das Widget gedrückt, tippe auf „Widget bearbeiten“ und wähle dann diese Kontaktgruppe aus.", "contactGroupUsedByWidgetSubtitle": "{count, plural, =1{Ein Widget auf deinem Homebildschirm zeigt Fotos aus dieser Gruppe.} other{{count} Widgets auf deinem Homebildschirm zeigen Fotos aus dieser Gruppe.}}", "@contactGroupUsedByWidgetSubtitle": { "placeholders": { @@ -1153,6 +1162,7 @@ "sticker": "Sticker", "stickers": "Sticker", "createSticker": "Sticker erstellen", + "stickerPickFromGallery": "Galerie", "noStickersYet": "Erstelle deinen ersten Sticker aus einem Foto.", "stickerAdded": "Sticker gespeichert", "stickerAlreadySaved": "Dieser Sticker ist bereits gespeichert", diff --git a/lib/src/localization/translations/en.arb b/lib/src/localization/translations/en.arb index 7bb04a51..c6101cff 100644 --- a/lib/src/localization/translations/en.arb +++ b/lib/src/localization/translations/en.arb @@ -376,6 +376,7 @@ "selectMembers": "Select members", "selectGroupName": "Select group name", "groupNameInput": "Group name", + "contactGroupNamePlaceholder": "Enter name here", "groupMembers": "Members", "addMember": "Add member", "createGroup": "Create group", @@ -1088,6 +1089,14 @@ "fileLimitReachedHintFree": "Record a shorter video, or upgrade your plan to send larger files.", "fileLimitReachedUpgrade": "Upgrade plan", "contactGroupUsedByWidget": "Home screen widget", + "contactGroupAddWidgetSubtitle": "Add a widget for this contact group.", + "contactGroupWidgetTutorialTitle": "Add a home screen widget", + "contactGroupWidgetTutorialAndroidStep1": "Touch and hold an empty area on your home screen, then tap Widgets.", + "contactGroupWidgetTutorialAndroidStep2": "Find twonly, then drag its widget onto your home screen.", + "contactGroupWidgetTutorialAndroidStep3": "Select this contact group in the setup screen, then tap Save.", + "contactGroupWidgetTutorialIosStep1": "Touch and hold an empty area on your Home Screen, then tap Edit and Add Widget.", + "contactGroupWidgetTutorialIosStep2": "Find twonly, choose a size, then tap Add Widget.", + "contactGroupWidgetTutorialIosStep3": "Touch and hold the widget, tap Edit Widget, then select this contact group.", "contactGroupUsedByWidgetSubtitle": "{count, plural, =1{A widget on your home screen shows photos from this group.} other{{count} widgets on your home screen show photos from this group.}}", "@contactGroupUsedByWidgetSubtitle": { "placeholders": { @@ -1163,6 +1172,7 @@ "sticker": "Sticker", "stickers": "Stickers", "createSticker": "Create sticker", + "stickerPickFromGallery": "Gallery", "noStickersYet": "Create your first sticker from a photo.", "stickerAdded": "Sticker saved", "stickerAlreadySaved": "This sticker is already saved", diff --git a/lib/src/services/stickers/sticker.service.dart b/lib/src/services/stickers/sticker.service.dart index da94c0f4..e05a2326 100644 --- a/lib/src/services/stickers/sticker.service.dart +++ b/lib/src/services/stickers/sticker.service.dart @@ -1,11 +1,14 @@ +import 'dart:io'; import 'dart:typed_data'; import 'package:convert/convert.dart'; import 'package:crypto/crypto.dart'; -import 'package:image_picker/image_picker.dart'; import 'package:twonly/locator.dart'; +import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart'; +import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; +import 'package:twonly/src/services/memories/memories_cloud.service.dart'; class StickerService { const StickerService._(); @@ -73,10 +76,40 @@ class StickerService { return sticker; } - static Future createFromGallery() async { - final image = await ImagePicker().pickImage(source: ImageSource.gallery); - if (image == null) return null; - return createFromPath(image.path); + /// Finds the best full-resolution local source for a stored memory. A + /// cloud-only memory is downloaded before its path is returned. + static Future resolveMediaPath(MediaFileService media) async { + File? readableSource() { + for (final file in [ + media.storedPath, + media.tempPath, + media.originalPath, + ]) { + final stat = file.statSync(); + if (stat.type != FileSystemEntityType.notFound && stat.size > 0) { + return file; + } + } + return null; + } + + var source = readableSource(); + if (source == null && media.mediaFile.cloudState == CloudState.uploaded) { + final downloaded = await MemoriesCloudService.downloadFromCloud( + media, + isThumbnail: false, + ); + if (downloaded) source = readableSource(); + } + return source?.path; + } + + static Future createFromMedia(MediaFileService media) async { + final path = await resolveMediaPath(media); + if (path == null) { + throw StateError('sticker source media is unavailable'); + } + return createFromPath(path); } static Future saveReceived(StickerData sticker) { diff --git a/lib/src/visual/components/sticker_picker.dart b/lib/src/visual/components/sticker_picker.dart index e3d68e23..e9b03293 100644 --- a/lib/src/visual/components/sticker_picker.dart +++ b/lib/src/visual/components/sticker_picker.dart @@ -6,6 +6,7 @@ import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/emoji_picker/bottom_action_bar.dart'; import 'package:twonly/src/visual/components/sticker_preview.dialog.dart'; import 'package:twonly/src/visual/loader/three_rotating_dots.loader.dart'; +import 'package:twonly/src/visual/views/memories/sticker_source_picker.view.dart'; class StickerPicker extends StatefulWidget { const StickerPicker({ @@ -32,8 +33,14 @@ class _StickerPickerState extends State { if (_creating) return; setState(() => _creating = true); try { - final sticker = await StickerService.createFromGallery(); - if (!mounted || sticker == null) return; + final imagePath = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const StickerSourcePickerView(), + ), + ); + if (!mounted || imagePath == null) return; + final sticker = await StickerService.createFromPath(imagePath); + if (!mounted) return; if (widget.selectCreatedSticker) widget.onStickerSelected(sticker); } on Object { if (!mounted) return; @@ -50,86 +57,103 @@ class _StickerPickerState extends State { final color = context.color; return ColoredBox( color: color.surfaceContainer, - child: Column( - children: [ - PickerBottomBar( - backgroundColor: color.surfaceContainer, - buttonIconColor: color.secondary, - emojiButtonLabel: 'Emoji', - stickerButtonLabel: context.lang.sticker, - showStickerButton: true, - stickerSelected: true, - onEmojiButtonPressed: widget.onEmojiPressed, - ), - Expanded( - child: StreamBuilder>( - stream: _stickers, - builder: (context, snapshot) { - if (!snapshot.hasData && !snapshot.hasError) { - return const Center(child: ThreeRotatingDots(size: 26)); - } - final stickers = snapshot.data ?? const []; - return GridView.builder( - padding: const EdgeInsets.all(12), - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 104, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - ), - itemCount: stickers.length + 1, - itemBuilder: (context, index) { - if (index == 0) { - return Semantics( - button: true, - label: context.lang.createSticker, - child: Material( - color: color.surfaceContainerHigh, - borderRadius: BorderRadius.circular(16), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: _creating ? null : _createSticker, - child: Center( - child: _creating - ? const ThreeRotatingDots(size: 26) - : Icon( - Icons.add_photo_alternate_outlined, - color: color.primary, - size: 32, - ), + child: StreamBuilder>( + stream: _stickers, + builder: (context, snapshot) { + final isLoading = !snapshot.hasData && !snapshot.hasError; + final stickers = snapshot.data ?? const []; + final hasStickers = stickers.isNotEmpty; + return Column( + children: [ + PickerBottomBar( + backgroundColor: color.surfaceContainer, + buttonIconColor: color.secondary, + emojiButtonLabel: 'Emoji', + stickerButtonLabel: context.lang.sticker, + showStickerButton: true, + stickerSelected: true, + onEmojiButtonPressed: widget.onEmojiPressed, + trailing: hasStickers + ? IconButton( + key: const Key('stickerPickerHeaderAdd'), + tooltip: context.lang.createSticker, + padding: const EdgeInsets.only(bottom: 2), + onPressed: _creating ? null : _createSticker, + icon: _creating + ? const ThreeRotatingDots(size: 22) + : Icon( + Icons.add_photo_alternate_outlined, + color: color.secondary, + ), + ) + : null, + ), + Expanded( + child: isLoading + ? const Center(child: ThreeRotatingDots(size: 26)) + : GridView.builder( + padding: const EdgeInsets.all(12), + gridDelegate: + const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 104, + mainAxisSpacing: 8, + crossAxisSpacing: 8, ), - ), - ), - ); - } - final sticker = stickers[index - 1]; - return Semantics( - button: true, - label: context.lang.sticker, - child: InkWell( - key: ValueKey(sticker.contentHash), - borderRadius: BorderRadius.circular(12), - onTap: () => widget.onStickerSelected(sticker), - onLongPress: () => showStickerPreview( - context, - StickerService.fromLocal(sticker), - collectionOnly: true, - ), - child: Padding( - padding: const EdgeInsets.all(5), - child: Image.memory( - sticker.webp, - fit: BoxFit.contain, - gaplessPlayback: true, - ), - ), + itemCount: stickers.length + (hasStickers ? 0 : 1), + itemBuilder: (context, index) { + if (!hasStickers) { + return Semantics( + key: const Key('stickerPickerEmptyAdd'), + button: true, + label: context.lang.createSticker, + child: Material( + color: color.surfaceContainerHigh, + borderRadius: BorderRadius.circular(16), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: _creating ? null : _createSticker, + child: Center( + child: _creating + ? const ThreeRotatingDots(size: 26) + : Icon( + Icons.add_photo_alternate_outlined, + color: color.primary, + size: 32, + ), + ), + ), + ), + ); + } + final sticker = stickers[index]; + return Semantics( + button: true, + label: context.lang.sticker, + child: InkWell( + key: ValueKey(sticker.contentHash), + borderRadius: BorderRadius.circular(12), + onTap: () => widget.onStickerSelected(sticker), + onLongPress: () => showStickerPreview( + context, + StickerService.fromLocal(sticker), + collectionOnly: true, + ), + child: Padding( + padding: const EdgeInsets.all(5), + child: Image.memory( + sticker.webp, + fit: BoxFit.contain, + gaplessPlayback: true, + ), + ), + ), + ); + }, ), - ); - }, - ); - }, - ), - ), - ], + ), + ], + ); + }, ), ); } diff --git a/lib/src/visual/views/chats/chat_messages.view.dart b/lib/src/visual/views/chats/chat_messages.view.dart index 651f0a21..eec2f09c 100644 --- a/lib/src/visual/views/chats/chat_messages.view.dart +++ b/lib/src/visual/views/chats/chat_messages.view.dart @@ -671,7 +671,12 @@ class _ChatMessagesViewState extends State } final group = _group!; return GestureDetector( - onTap: () => FocusScope.of(context).unfocus(), + onTap: () { + FocusScope.of(context).unfocus(); + if (_emojiPickerShowing) { + setState(() => _emojiPickerShowing = false); + } + }, child: Scaffold( resizeToAvoidBottomInset: !_emojiPickerShowing, appBar: AppBar( @@ -869,6 +874,7 @@ class _ChatMessagesViewState extends State quotesMessage: quotesMessage, textFieldFocus: textFieldFocus!, composing: _composing, + emojiPickerShowing: _emojiPickerShowing, onMessageSend: () { setState(() { quotesMessage = null; diff --git a/lib/src/visual/views/chats/chat_messages_components/message_input.dart b/lib/src/visual/views/chats/chat_messages_components/message_input.dart index 0a87c3df..36099a63 100644 --- a/lib/src/visual/views/chats/chat_messages_components/message_input.dart +++ b/lib/src/visual/views/chats/chat_messages_components/message_input.dart @@ -33,6 +33,7 @@ class MessageInput extends StatefulWidget { required this.textFieldFocus, required this.onMessageSend, required this.onEmojiVisibilityChanged, + required this.emojiPickerShowing, required this.composing, super.key, }); @@ -42,6 +43,7 @@ class MessageInput extends StatefulWidget { final Message? quotesMessage; final VoidCallback onMessageSend; final ValueChanged onEmojiVisibilityChanged; + final bool emojiPickerShowing; /// Published so the chat-open heartbeat can stay quiet while the typing /// announcement is already covering this conversation. @@ -71,7 +73,6 @@ class _MessageInputState extends State late final TextEditingController _textFieldController; late final RecorderController recorderController; final bool isApple = Platform.isIOS; - bool _emojiShowing = false; bool _stickerShowing = false; bool _switchingToSystemKeyboard = false; double _emojiPickerHeight = 300; @@ -226,7 +227,7 @@ class _MessageInputState extends State void _handleTextFocusChange() { if (widget.textFieldFocus.hasFocus && - _emojiShowing && + widget.emojiPickerShowing && !_switchingToSystemKeyboard) { _showSystemKeyboard(); } @@ -246,7 +247,7 @@ class _MessageInputState extends State } void _toggleKeyboard() { - if (_emojiShowing) { + if (widget.emojiPickerShowing) { _showSystemKeyboard(); } else { _showEmojiPicker(); @@ -261,7 +262,6 @@ class _MessageInputState extends State if (keyboardHeight > 0) { _emojiPickerHeight = keyboardHeight; } - _emojiShowing = true; _stickerShowing = false; }); widget.onEmojiVisibilityChanged(true); @@ -295,7 +295,6 @@ class _MessageInputState extends State _keyboardTransitionTimer?.cancel(); setState(() { _switchingToSystemKeyboard = false; - _emojiShowing = false; }); widget.onEmojiVisibilityChanged(false); } @@ -490,8 +489,8 @@ class _MessageInputState extends State right: 8, ), child: FaIcon( - size: _emojiShowing ? 18 : 20, - _emojiShowing + size: widget.emojiPickerShowing ? 18 : 20, + widget.emojiPickerShowing ? FontAwesomeIcons.keyboard : FontAwesomeIcons.faceSmile, ), @@ -771,7 +770,7 @@ class _MessageInputState extends State ), ), Offstage( - offstage: !_emojiShowing, + offstage: !widget.emojiPickerShowing, child: EmojiPicker( alternateView: _stickerShowing ? StickerPicker( diff --git a/lib/src/visual/views/contact/contact_group_settings.view.dart b/lib/src/visual/views/contact/contact_group_settings.view.dart index 08c548dc..df5208b0 100644 --- a/lib/src/visual/views/contact/contact_group_settings.view.dart +++ b/lib/src/visual/views/contact/contact_group_settings.view.dart @@ -13,8 +13,8 @@ import 'package:twonly/src/visual/components/custom_color_picker_dialog.comp.dar import 'package:twonly/src/visual/components/emoji_picker.bottom.dart'; import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/components/verification_badge.comp.dart'; -import 'package:twonly/src/visual/decorations/input_text.decoration.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart'; +import 'package:twonly/src/visual/elements/my_input.element.dart'; import 'package:twonly/src/visual/views/camera/share_image_editor_components/layer_data.dart'; class ContactGroupSettingsView extends StatefulWidget { @@ -67,6 +67,7 @@ class _ContactGroupSettingsViewState extends State { ]; late final TextEditingController _nameController; + final TextEditingController _memberFilterController = TextEditingController(); late int _backgroundColor; late int _textColor; late bool _showAsShortcut; @@ -86,6 +87,7 @@ class _ContactGroupSettingsViewState extends State { /// members are what makes those widgets able to receive anything, so the /// group cannot be deleted while any of them is still placed. int _widgetCount = 0; + bool _widgetUsageLoaded = false; bool get _isEditing => widget.contactGroup != null; @@ -161,6 +163,7 @@ class _ContactGroupSettingsViewState extends State { unawaited(subscription.cancel()); } _nameController.dispose(); + _memberFilterController.dispose(); super.dispose(); } @@ -265,7 +268,70 @@ class _ContactGroupSettingsViewState extends State { .where((placed) => placed.contactGroupIds.contains(contactGroupId)) .length; if (!mounted) return; - setState(() => _widgetCount = count); + setState(() { + _widgetCount = count; + _widgetUsageLoaded = true; + }); + } + + Future _showWidgetTutorial() async { + final isIOS = Theme.of(context).platform == TargetPlatform.iOS; + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(context.lang.contactGroupWidgetTutorialTitle), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _widgetTutorialStep( + 1, + isIOS + ? context.lang.contactGroupWidgetTutorialIosStep1 + : context.lang.contactGroupWidgetTutorialAndroidStep1, + ), + _widgetTutorialStep( + 2, + isIOS + ? context.lang.contactGroupWidgetTutorialIosStep2 + : context.lang.contactGroupWidgetTutorialAndroidStep2, + ), + _widgetTutorialStep( + 3, + isIOS + ? context.lang.contactGroupWidgetTutorialIosStep3 + : context.lang.contactGroupWidgetTutorialAndroidStep3, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(context.lang.ok), + ), + ], + ), + ); + } + + Widget _widgetTutorialStep(int number, String text) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 14, + backgroundColor: context.color.primaryContainer, + foregroundColor: context.color.onPrimaryContainer, + child: Text('$number'), + ), + const SizedBox(width: 12), + Expanded(child: Text(text)), + ], + ), + ); } Future _delete() async { @@ -365,6 +431,7 @@ class _ContactGroupSettingsViewState extends State { Widget _nameEditor() { final hasBackground = contactGroupHasBackground(_backgroundColor); final fontSize = contactGroupFontSize(13, _backgroundColor); + final placeholder = context.lang.contactGroupNamePlaceholder; final style = TextStyle( color: Color(_textColor), fontSize: fontSize, @@ -373,7 +440,10 @@ class _ContactGroupSettingsViewState extends State { // The field has no intrinsic width, so measure the text to let the badge // hug its content just like the rendered label does. final painter = TextPainter( - text: TextSpan(text: _nameController.text, style: style), + text: TextSpan( + text: _nameController.text.isEmpty ? placeholder : _nameController.text, + style: style, + ), textDirection: Directionality.of(context), textScaler: MediaQuery.textScalerOf(context), )..layout(); @@ -400,7 +470,11 @@ class _ContactGroupSettingsViewState extends State { textAlign: TextAlign.center, textCapitalization: TextCapitalization.words, style: style, - decoration: const InputDecoration( + decoration: InputDecoration( + hintText: placeholder, + hintStyle: style.copyWith( + color: style.color?.withValues(alpha: 0.65), + ), border: InputBorder.none, isDense: true, contentPadding: EdgeInsets.zero, @@ -532,6 +606,25 @@ class _ContactGroupSettingsViewState extends State { value: _showAsShortcut, onChanged: (value) => setState(() => _showAsShortcut = value), ), + if (_showAsShortcut) + Container( + margin: const EdgeInsets.only(left: 16, bottom: 8), + decoration: BoxDecoration( + color: context.color.surfaceContainerLow, + borderRadius: BorderRadius.circular(16), + ), + clipBehavior: Clip.antiAlias, + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + leading: const Icon(Icons.emoji_emotions_outlined), + title: Text(context.lang.selectEmoji), + trailing: Text( + _emoji ?? '+', + style: const TextStyle(fontSize: 24), + ), + onTap: _selectEmoji, + ), + ), SwitchListTile.adaptive( contentPadding: EdgeInsets.zero, title: Text(context.lang.contactGroupShareStories), @@ -539,30 +632,25 @@ class _ContactGroupSettingsViewState extends State { value: _shareStories, onChanged: (value) => setState(() => _shareStories = value), ), - if (_showAsShortcut) + if (_isEditing && _widgetUsageLoaded) ListTile( contentPadding: EdgeInsets.zero, - title: Text(context.lang.selectEmoji), - trailing: Text( - _emoji ?? '+', - style: const TextStyle(fontSize: 24), - ), - onTap: _selectEmoji, - ), - if (_widgetCount > 0) - // Stated rather than offered as a switch: which groups a widget - // draws from is chosen on the home screen, not here. - ListTile( - contentPadding: EdgeInsets.zero, - title: Text(context.lang.contactGroupUsedByWidget), - subtitle: Text( - context.lang.contactGroupUsedByWidgetSubtitle(_widgetCount), - ), - trailing: FaIcon( - FontAwesomeIcons.image, - size: 18, + leading: Icon( + Icons.widgets_outlined, color: context.color.primary, ), + title: Text(context.lang.contactGroupUsedByWidget), + subtitle: Text( + _widgetCount > 0 + ? context.lang.contactGroupUsedByWidgetSubtitle( + _widgetCount, + ) + : context.lang.contactGroupAddWidgetSubtitle, + ), + trailing: _widgetCount == 0 + ? const Icon(Icons.chevron_right) + : null, + onTap: _widgetCount == 0 ? _showWidgetTutorial : null, ), const Divider(height: 40), Text( @@ -570,12 +658,13 @@ class _ContactGroupSettingsViewState extends State { style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 12), - TextField( + MyInput( + controller: _memberFilterController, + dense: true, + fontWeight: FontWeight.normal, + hintText: context.lang.shareImageSearchAllContacts, + prefixIcon: const Icon(Icons.search, size: 20), onChanged: (value) => setState(() => _memberFilter = value), - decoration: getInputDecoration( - context, - context.lang.shareImageSearchAllContacts, - ), ), for (final entry in members) _memberTile(entry), ], diff --git a/lib/src/visual/views/groups/group_create_select_group_name.view.dart b/lib/src/visual/views/groups/group_create_select_group_name.view.dart index 3d9e62cd..ec73de50 100644 --- a/lib/src/visual/views/groups/group_create_select_group_name.view.dart +++ b/lib/src/visual/views/groups/group_create_select_group_name.view.dart @@ -11,8 +11,8 @@ import 'package:twonly/src/visual/components/contact_groups.comp.dart'; import 'package:twonly/src/visual/components/flame_counter.comp.dart'; import 'package:twonly/src/visual/components/verification_badge.comp.dart'; import 'package:twonly/src/visual/context_menu/user.context_menu.dart'; -import 'package:twonly/src/visual/decorations/input_text.decoration.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart'; +import 'package:twonly/src/visual/elements/my_input.element.dart'; class GroupCreateSelectGroupNameView extends StatefulWidget { const GroupCreateSelectGroupNameView({ @@ -101,16 +101,11 @@ class _GroupCreateSelectGroupNameViewState children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 10), - child: TextField( - onChanged: (_) async { - setState(() {}); - }, - autofocus: true, + child: MyInput( controller: textFieldGroupName, - decoration: getInputDecoration( - context, - context.lang.groupNameInput, - ), + autofocus: true, + hintText: context.lang.groupNameInput, + onChanged: (_) => setState(() {}), ), ), const SizedBox(height: 10), diff --git a/lib/src/visual/views/groups/group_create_select_members.view.dart b/lib/src/visual/views/groups/group_create_select_members.view.dart index 484b644f..932673ce 100644 --- a/lib/src/visual/views/groups/group_create_select_members.view.dart +++ b/lib/src/visual/views/groups/group_create_select_members.view.dart @@ -15,9 +15,9 @@ import 'package:twonly/src/visual/components/flame_counter.comp.dart'; import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/components/verification_badge.comp.dart'; import 'package:twonly/src/visual/context_menu/user.context_menu.dart'; -import 'package:twonly/src/visual/decorations/input_text.decoration.dart'; import 'package:twonly/src/visual/elements/contact_chip.element.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart'; +import 'package:twonly/src/visual/elements/my_input.element.dart'; import 'package:twonly/src/visual/views/groups/group_create_select_group_name.view.dart'; class GroupCreateSelectMembersView extends StatefulWidget { @@ -162,15 +162,13 @@ class _StartNewChatView extends State { children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 10), - child: TextField( - onChanged: (_) async { - await filterUsers(); - }, + child: MyInput( controller: searchUserName, - decoration: getInputDecoration( - context, - context.lang.shareImageSearchAllContacts, - ), + dense: true, + fontWeight: FontWeight.normal, + hintText: context.lang.shareImageSearchAllContacts, + prefixIcon: const Icon(Icons.search, size: 20), + onChanged: (_) => unawaited(filterUsers()), ), ), const SizedBox(height: 10), diff --git a/lib/src/visual/views/memories/components/synchronized_viewer_actions_toolbar.comp.dart b/lib/src/visual/views/memories/components/synchronized_viewer_actions_toolbar.comp.dart index da53095b..a29b1fac 100644 --- a/lib/src/visual/views/memories/components/synchronized_viewer_actions_toolbar.comp.dart +++ b/lib/src/visual/views/memories/components/synchronized_viewer_actions_toolbar.comp.dart @@ -9,9 +9,12 @@ class SynchronizedViewerActionsToolbarComp extends StatelessWidget { required this.onExport, required this.onToggleFavorite, required this.onDelete, + required this.showCreateStickerButton, + required this.onCreateSticker, this.showStoreButton = false, this.onStore, this.isImageSaving = false, + this.isCreatingSticker = false, super.key, }); @@ -20,9 +23,12 @@ class SynchronizedViewerActionsToolbarComp extends StatelessWidget { final VoidCallback onExport; final VoidCallback onToggleFavorite; final VoidCallback onDelete; + final bool showCreateStickerButton; + final VoidCallback onCreateSticker; final bool showStoreButton; final VoidCallback? onStore; final bool isImageSaving; + final bool isCreatingSticker; @override Widget build(BuildContext context) { @@ -80,6 +86,27 @@ class SynchronizedViewerActionsToolbarComp extends StatelessWidget { : context.lang.galleryActionFavorite, onTap: onToggleFavorite, ), + if (showCreateStickerButton) + _ToolbarAction( + icon: isCreatingSticker + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + context.appColor(AppColor.mediaForeground), + ), + ), + ) + : FaIcon( + FontAwesomeIcons.wandMagicSparkles, + color: context.appColor(AppColor.mediaForeground), + size: 18, + ), + label: context.lang.createSticker, + onTap: isCreatingSticker ? null : onCreateSticker, + ), _ToolbarAction( icon: Icon( Icons.delete, diff --git a/lib/src/visual/views/memories/sticker_source_picker.view.dart b/lib/src/visual/views/memories/sticker_source_picker.view.dart new file mode 100644 index 00000000..35d9a642 --- /dev/null +++ b/lib/src/visual/views/memories/sticker_source_picker.view.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:twonly/src/database/tables/mediafiles.table.dart'; +import 'package:twonly/src/model/memory_item.model.dart'; +import 'package:twonly/src/services/memories/memories.service.dart'; +import 'package:twonly/src/services/stickers/sticker.service.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/loader/three_rotating_dots.loader.dart'; +import 'package:twonly/src/visual/views/memories/components/memory_thumbnail.comp.dart'; + +/// Selects a full-resolution image from twonly Memories, with an explicit +/// escape hatch to the device gallery in the top app bar. +class StickerSourcePickerView extends StatefulWidget { + const StickerSourcePickerView({super.key}); + + @override + State createState() => + _StickerSourcePickerViewState(); +} + +class _StickerSourcePickerViewState extends State { + late final MemoriesService _memories = MemoriesService(); + String? _resolvingMediaId; + + @override + void dispose() { + _memories.dispose(); + super.dispose(); + } + + Future _pickFromGallery() async { + if (_resolvingMediaId != null) return; + final image = await ImagePicker().pickImage(source: ImageSource.gallery); + if (!mounted || image == null) return; + Navigator.pop(context, image.path); + } + + Future _selectMemory(MemoryItem item) async { + if (_resolvingMediaId != null) return; + final mediaId = item.mediaService.mediaFile.mediaId; + setState(() => _resolvingMediaId = mediaId); + try { + final path = await StickerService.resolveMediaPath(item.mediaService); + if (!mounted) return; + if (path == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.lang.stickerCreateFailed)), + ); + return; + } + Navigator.pop(context, path); + } on Object { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.lang.stickerCreateFailed)), + ); + } finally { + if (mounted) setState(() => _resolvingMediaId = null); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(context.lang.createSticker), + actions: [ + TextButton.icon( + onPressed: _resolvingMediaId == null ? _pickFromGallery : null, + icon: const Icon(Icons.photo_library_outlined), + label: Text(context.lang.stickerPickFromGallery), + ), + const SizedBox(width: 8), + ], + ), + body: StreamBuilder( + stream: _memories.watchState, + initialData: _memories.currentState, + builder: (context, snapshot) { + final state = snapshot.data ?? _memories.currentState; + final images = state.galleryItems + .where( + (item) => item.mediaService.mediaFile.type == MediaType.image, + ) + .toList(growable: false); + + if (images.isEmpty && state.isLoading) { + return const Center(child: ThreeRotatingDots(size: 32)); + } + if (images.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + context.lang.memoriesEmpty, + textAlign: TextAlign.center, + style: TextStyle(color: context.color.onSurfaceVariant), + ), + ), + ); + } + + return Stack( + children: [ + GridView.builder( + padding: const EdgeInsets.all(8), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: 2, + crossAxisSpacing: 2, + childAspectRatio: 9 / 16, + ), + itemCount: images.length, + itemBuilder: (context, index) { + final item = images[index]; + return MemoriesThumbnailComp( + galleryItem: item, + index: index, + onTap: () => _selectMemory(item), + ); + }, + ), + if (_resolvingMediaId != null) + const Positioned.fill( + child: ColoredBox( + color: Color(0x66000000), + child: Center(child: ThreeRotatingDots(size: 32)), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/src/visual/views/memories/synchronized_viewer.view.dart b/lib/src/visual/views/memories/synchronized_viewer.view.dart index 76c23189..d62aa5f4 100644 --- a/lib/src/visual/views/memories/synchronized_viewer.view.dart +++ b/lib/src/visual/views/memories/synchronized_viewer.view.dart @@ -7,6 +7,7 @@ import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/memory_item.model.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; +import 'package:twonly/src/services/stickers/sticker.service.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/delete_memories_dialog.comp.dart'; @@ -41,6 +42,7 @@ class _SynchronizedImageViewerScreenState final Set _favoritedMediaIds = {}; bool _isSaving = false; + bool _isCreatingSticker = false; final Set _storedMediaIds = {}; final Set _precachedMediaIds = {}; @@ -95,6 +97,26 @@ class _SynchronizedImageViewerScreenState } } + Future _createSticker() async { + if (_isCreatingSticker) return; + final media = widget.galleryItems[_currentIndex].mediaService; + setState(() => _isCreatingSticker = true); + try { + await StickerService.createFromMedia(media); + if (!mounted) return; + showSnackbar( + context, + context.lang.stickerAdded, + level: SnackbarLevel.success, + ); + } on Object { + if (!mounted) return; + showSnackbar(context, context.lang.stickerCreateFailed); + } finally { + if (mounted) setState(() => _isCreatingSticker = false); + } + } + Future _toggleFavorite(String mediaId) async { final wasFavorite = _favoritedMediaIds.contains(mediaId); final isFavoriteNow = !wasFavorite; @@ -365,6 +387,11 @@ class _SynchronizedImageViewerScreenState onExport: _exportFile, onToggleFavorite: () => _toggleFavorite(currentMediaId), + showCreateStickerButton: + orgMediaService.mediaFile.type == + MediaType.image, + onCreateSticker: _createSticker, + isCreatingSticker: _isCreatingSticker, onDelete: _deleteFile, showStoreButton: !_storedMediaIds.contains( currentMediaId, diff --git a/test/visual/components/sticker_picker_test.dart b/test/visual/components/sticker_picker_test.dart index 722ade94..16271256 100644 --- a/test/visual/components/sticker_picker_test.dart +++ b/test/visual/components/sticker_picker_test.dart @@ -2,10 +2,12 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:crypto/crypto.dart'; +import 'package:drift/drift.dart' show DatabaseConnection; import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:twonly/globals.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/localization/generated/app_localizations.dart'; @@ -16,6 +18,7 @@ import 'package:twonly/src/visual/components/sticker_picker.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/themes/light.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_sticker.entry.dart'; +import 'package:twonly/src/visual/views/memories/sticker_source_picker.view.dart'; // A small valid image for exercising image widgets; protocol codec validation // and real transparent WebP inference are covered by the Rust sticker tests. @@ -50,10 +53,17 @@ Future _settle(WidgetTester tester) async { void main() { late TwonlyDB database; + setUpAll(AppEnvironment.initTesting); + setUp(() async { SharedPreferences.setMockInitialValues({}); await locator.reset(); - database = TwonlyDB(NativeDatabase.memory()); + database = TwonlyDB.forTesting( + DatabaseConnection( + NativeDatabase.memory(), + closeStreamsSynchronously: true, + ), + ); locator.registerSingleton(database); await database.customSelect('SELECT 1').get(); }); @@ -115,6 +125,61 @@ void main() { }, ); + testWidgets('new sticker opens Memories with a Gallery option', ( + tester, + ) async { + await tester.pumpWidget( + _app( + SizedBox( + height: 300, + child: StickerPicker( + onEmojiPressed: () {}, + onStickerSelected: (_) {}, + ), + ), + ), + ); + await _settle(tester); + + await tester.tap(find.byIcon(Icons.add_photo_alternate_outlined)); + await _settle(tester); + + expect(find.byType(StickerSourcePickerView), findsOneWidget); + expect(find.text('Gallery'), findsOneWidget); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 100)), + ); + await tester.pump(); + }); + + testWidgets('moves add action to the header once a sticker exists', ( + tester, + ) async { + await tester.pumpWidget( + _app( + SizedBox( + height: 300, + child: StickerPicker( + onEmojiPressed: () {}, + onStickerSelected: (_) {}, + ), + ), + ), + ); + await _settle(tester); + + expect(find.byKey(const Key('stickerPickerEmptyAdd')), findsOneWidget); + expect(find.byKey(const Key('stickerPickerHeaderAdd')), findsNothing); + + await tester.runAsync(() => StickerService.saveReceived(_sticker())); + await _settle(tester); + + expect(find.byKey(const Key('stickerPickerEmptyAdd')), findsNothing); + expect(find.byKey(const Key('stickerPickerHeaderAdd')), findsOneWidget); + }); + testWidgets('chat tap previews without saving; buttons save then remove', ( tester, ) async { diff --git a/test/visual/memories/sticker_viewer_action_test.dart b/test/visual/memories/sticker_viewer_action_test.dart new file mode 100644 index 00000000..f4d0c7aa --- /dev/null +++ b/test/visual/memories/sticker_viewer_action_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:twonly/src/localization/generated/app_localizations.dart'; +import 'package:twonly/src/visual/themes/light.dart'; +import 'package:twonly/src/visual/views/memories/components/synchronized_viewer_actions_toolbar.comp.dart'; + +Widget _app({required bool showCreateSticker, VoidCallback? onCreateSticker}) { + return MaterialApp( + theme: lightTheme, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Center( + child: SizedBox( + width: 700, + child: SynchronizedViewerActionsToolbarComp( + isFavorite: false, + onShare: () {}, + onExport: () {}, + onToggleFavorite: () {}, + onDelete: () {}, + showCreateStickerButton: showCreateSticker, + onCreateSticker: onCreateSticker ?? () {}, + ), + ), + ), + ), + ); +} + +void main() { + testWidgets('shows and invokes sticker creation for image media', ( + tester, + ) async { + var invoked = false; + await tester.pumpWidget( + _app( + showCreateSticker: true, + onCreateSticker: () => invoked = true, + ), + ); + + expect(find.text('Create sticker'), findsOneWidget); + await tester.tap(find.text('Create sticker')); + expect(invoked, isTrue); + }); + + testWidgets('hides sticker creation for unsupported media', (tester) async { + await tester.pumpWidget(_app(showCreateSticker: false)); + + expect(find.text('Create sticker'), findsNothing); + }); +}