fix smaller layout issues and add sticker creation button to stored images

This commit is contained in:
otsmr 2026-10-05 21:15:22 +02:00
parent 76018bd780
commit 4337db58be
19 changed files with 798 additions and 143 deletions

View file

@ -1906,6 +1906,12 @@ abstract class AppLocalizations {
/// **'Group name'** /// **'Group name'**
String get groupNameInput; 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. /// No description provided for @groupMembers.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -4774,6 +4780,54 @@ abstract class AppLocalizations {
/// **'Home screen widget'** /// **'Home screen widget'**
String get contactGroupUsedByWidget; 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. /// No description provided for @contactGroupUsedByWidgetSubtitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -5014,6 +5068,12 @@ abstract class AppLocalizations {
/// **'Create sticker'** /// **'Create sticker'**
String get createSticker; String get createSticker;
/// No description provided for @stickerPickFromGallery.
///
/// In en, this message translates to:
/// **'Gallery'**
String get stickerPickFromGallery;
/// No description provided for @noStickersYet. /// No description provided for @noStickersYet.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -1033,6 +1033,9 @@ class AppLocalizationsAr extends AppLocalizations {
@override @override
String get groupNameInput => 'اسم المجموعة'; String get groupNameInput => 'اسم المجموعة';
@override
String get contactGroupNamePlaceholder => 'أدخل الاسم هنا';
@override @override
String get groupMembers => 'الأعضاء'; String get groupMembers => 'الأعضاء';
@ -2779,6 +2782,38 @@ class AppLocalizationsAr extends AppLocalizations {
@override @override
String get contactGroupUsedByWidget => 'أداة الشاشة الرئيسية'; 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 @override
String contactGroupUsedByWidgetSubtitle(int count) { String contactGroupUsedByWidgetSubtitle(int count) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(
@ -2954,6 +2989,9 @@ class AppLocalizationsAr extends AppLocalizations {
@override @override
String get createSticker => 'إنشاء ملصق'; String get createSticker => 'إنشاء ملصق';
@override
String get stickerPickFromGallery => 'المعرض';
@override @override
String get noStickersYet => 'أنشئ ملصقك الأول من صورة.'; String get noStickersYet => 'أنشئ ملصقك الأول من صورة.';

View file

@ -1028,6 +1028,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get groupNameInput => 'Gruppennamen'; String get groupNameInput => 'Gruppennamen';
@override
String get contactGroupNamePlaceholder => 'Namen hier eingeben';
@override @override
String get groupMembers => 'Mitglieder'; String get groupMembers => 'Mitglieder';
@ -2785,6 +2788,38 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get contactGroupUsedByWidget => 'Homebildschirm-Widget'; 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 @override
String contactGroupUsedByWidgetSubtitle(int count) { String contactGroupUsedByWidgetSubtitle(int count) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(
@ -2955,6 +2990,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get createSticker => 'Sticker erstellen'; String get createSticker => 'Sticker erstellen';
@override
String get stickerPickFromGallery => 'Galerie';
@override @override
String get noStickersYet => 'Erstelle deinen ersten Sticker aus einem Foto.'; String get noStickersYet => 'Erstelle deinen ersten Sticker aus einem Foto.';

View file

@ -1022,6 +1022,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get groupNameInput => 'Group name'; String get groupNameInput => 'Group name';
@override
String get contactGroupNamePlaceholder => 'Enter name here';
@override @override
String get groupMembers => 'Members'; String get groupMembers => 'Members';
@ -2760,6 +2763,37 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get contactGroupUsedByWidget => 'Home screen widget'; 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 @override
String contactGroupUsedByWidgetSubtitle(int count) { String contactGroupUsedByWidgetSubtitle(int count) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(
@ -2926,6 +2960,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get createSticker => 'Create sticker'; String get createSticker => 'Create sticker';
@override
String get stickerPickFromGallery => 'Gallery';
@override @override
String get noStickersYet => 'Create your first sticker from a photo.'; String get noStickersYet => 'Create your first sticker from a photo.';

View file

@ -301,6 +301,7 @@
"selectMembers": "اختر الأعضاء", "selectMembers": "اختر الأعضاء",
"selectGroupName": "اختر اسم المجموعة", "selectGroupName": "اختر اسم المجموعة",
"groupNameInput": "اسم المجموعة", "groupNameInput": "اسم المجموعة",
"contactGroupNamePlaceholder": "أدخل الاسم هنا",
"groupMembers": "الأعضاء", "groupMembers": "الأعضاء",
"addMember": "إضافة عضو", "addMember": "إضافة عضو",
"createGroup": "إنشاء مجموعة", "createGroup": "إنشاء مجموعة",
@ -779,6 +780,14 @@
"fileLimitReachedHintFree": "سجّل فيديو أقصر، أو قم بترقية خطتك لإرسال ملفات أكبر.", "fileLimitReachedHintFree": "سجّل فيديو أقصر، أو قم بترقية خطتك لإرسال ملفات أكبر.",
"fileLimitReachedUpgrade": "ترقية الخطة", "fileLimitReachedUpgrade": "ترقية الخطة",
"contactGroupUsedByWidget": "أداة الشاشة الرئيسية", "contactGroupUsedByWidget": "أداة الشاشة الرئيسية",
"contactGroupAddWidgetSubtitle": "أضف أداة لمجموعة جهات الاتصال هذه.",
"contactGroupWidgetTutorialTitle": "إضافة أداة إلى الشاشة الرئيسية",
"contactGroupWidgetTutorialAndroidStep1": "المس مطولاً مساحة فارغة على شاشتك الرئيسية، ثم اضغط على «الأدوات».",
"contactGroupWidgetTutorialAndroidStep2": "ابحث عن twonly، ثم اسحب أداته إلى شاشتك الرئيسية.",
"contactGroupWidgetTutorialAndroidStep3": "اختر مجموعة جهات الاتصال هذه في شاشة الإعداد، ثم اضغط على «حفظ».",
"contactGroupWidgetTutorialIosStep1": "المس مطولاً مساحة فارغة على شاشتك الرئيسية، ثم اضغط على «تحرير» و«إضافة أداة».",
"contactGroupWidgetTutorialIosStep2": "ابحث عن twonly، واختر حجماً، ثم اضغط على «إضافة أداة».",
"contactGroupWidgetTutorialIosStep3": "المس الأداة مطولاً، واضغط على «تحرير الأداة»، ثم اختر مجموعة جهات الاتصال هذه.",
"contactGroupUsedByWidgetSubtitle": "{count, plural, =1{تعرض أداة على شاشتك الرئيسية صورًا من هذه المجموعة.} =2{تعرض أداتان على شاشتك الرئيسية صورًا من هذه المجموعة.} few{تعرض {count} أدوات على شاشتك الرئيسية صورًا من هذه المجموعة.} many{تعرض {count} أداةً على شاشتك الرئيسية صورًا من هذه المجموعة.} other{تعرض {count} أداة على شاشتك الرئيسية صورًا من هذه المجموعة.}}", "contactGroupUsedByWidgetSubtitle": "{count, plural, =1{تعرض أداة على شاشتك الرئيسية صورًا من هذه المجموعة.} =2{تعرض أداتان على شاشتك الرئيسية صورًا من هذه المجموعة.} few{تعرض {count} أدوات على شاشتك الرئيسية صورًا من هذه المجموعة.} many{تعرض {count} أداةً على شاشتك الرئيسية صورًا من هذه المجموعة.} other{تعرض {count} أداة على شاشتك الرئيسية صورًا من هذه المجموعة.}}",
"contactGroupDeleteBlockedByWidget": "{count, plural, =1{تستخدم أداةٌ على شاشتك الرئيسية مجموعة جهات الاتصال هذه. أزل تلك الأداة أولًا، ثم يمكنك حذف المجموعة.} =2{تستخدم أداتان على شاشتك الرئيسية مجموعة جهات الاتصال هذه. أزل هاتين الأداتين أولًا، ثم يمكنك حذف المجموعة.} few{تستخدم {count} أدوات على شاشتك الرئيسية مجموعة جهات الاتصال هذه. أزل تلك الأدوات أولًا، ثم يمكنك حذف المجموعة.} many{تستخدم {count} أداةً على شاشتك الرئيسية مجموعة جهات الاتصال هذه. أزل تلك الأدوات أولًا، ثم يمكنك حذف المجموعة.} other{تستخدم {count} أداة على شاشتك الرئيسية مجموعة جهات الاتصال هذه. أزل تلك الأدوات أولًا، ثم يمكنك حذف المجموعة.}}", "contactGroupDeleteBlockedByWidget": "{count, plural, =1{تستخدم أداةٌ على شاشتك الرئيسية مجموعة جهات الاتصال هذه. أزل تلك الأداة أولًا، ثم يمكنك حذف المجموعة.} =2{تستخدم أداتان على شاشتك الرئيسية مجموعة جهات الاتصال هذه. أزل هاتين الأداتين أولًا، ثم يمكنك حذف المجموعة.} few{تستخدم {count} أدوات على شاشتك الرئيسية مجموعة جهات الاتصال هذه. أزل تلك الأدوات أولًا، ثم يمكنك حذف المجموعة.} many{تستخدم {count} أداةً على شاشتك الرئيسية مجموعة جهات الاتصال هذه. أزل تلك الأدوات أولًا، ثم يمكنك حذف المجموعة.} other{تستخدم {count} أداة على شاشتك الرئيسية مجموعة جهات الاتصال هذه. أزل تلك الأدوات أولًا، ثم يمكنك حذف المجموعة.}}",
"webxdcStoreMenu": "تطبيق", "webxdcStoreMenu": "تطبيق",
@ -840,6 +849,7 @@
"sticker": "ملصق", "sticker": "ملصق",
"stickers": "الملصقات", "stickers": "الملصقات",
"createSticker": "إنشاء ملصق", "createSticker": "إنشاء ملصق",
"stickerPickFromGallery": "المعرض",
"noStickersYet": "أنشئ ملصقك الأول من صورة.", "noStickersYet": "أنشئ ملصقك الأول من صورة.",
"stickerAdded": "تم حفظ الملصق", "stickerAdded": "تم حفظ الملصق",
"stickerAlreadySaved": "هذا الملصق محفوظ بالفعل", "stickerAlreadySaved": "هذا الملصق محفوظ بالفعل",

View file

@ -363,6 +363,7 @@
"selectMembers": "Mitglieder auswählen", "selectMembers": "Mitglieder auswählen",
"selectGroupName": "Gruppennamen wählen", "selectGroupName": "Gruppennamen wählen",
"groupNameInput": "Gruppennamen", "groupNameInput": "Gruppennamen",
"contactGroupNamePlaceholder": "Namen hier eingeben",
"groupMembers": "Mitglieder", "groupMembers": "Mitglieder",
"createGroup": "Gruppe erstellen", "createGroup": "Gruppe erstellen",
"addMember": "Mitglied hinzufügen", "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.", "fileLimitReachedHintFree": "Nimm ein kürzeres Video auf oder wechsle den Tarif, um größere Dateien zu senden.",
"fileLimitReachedUpgrade": "Tarif wechseln", "fileLimitReachedUpgrade": "Tarif wechseln",
"contactGroupUsedByWidget": "Homebildschirm-Widget", "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": "{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": { "@contactGroupUsedByWidgetSubtitle": {
"placeholders": { "placeholders": {
@ -1153,6 +1162,7 @@
"sticker": "Sticker", "sticker": "Sticker",
"stickers": "Sticker", "stickers": "Sticker",
"createSticker": "Sticker erstellen", "createSticker": "Sticker erstellen",
"stickerPickFromGallery": "Galerie",
"noStickersYet": "Erstelle deinen ersten Sticker aus einem Foto.", "noStickersYet": "Erstelle deinen ersten Sticker aus einem Foto.",
"stickerAdded": "Sticker gespeichert", "stickerAdded": "Sticker gespeichert",
"stickerAlreadySaved": "Dieser Sticker ist bereits gespeichert", "stickerAlreadySaved": "Dieser Sticker ist bereits gespeichert",

View file

@ -376,6 +376,7 @@
"selectMembers": "Select members", "selectMembers": "Select members",
"selectGroupName": "Select group name", "selectGroupName": "Select group name",
"groupNameInput": "Group name", "groupNameInput": "Group name",
"contactGroupNamePlaceholder": "Enter name here",
"groupMembers": "Members", "groupMembers": "Members",
"addMember": "Add member", "addMember": "Add member",
"createGroup": "Create group", "createGroup": "Create group",
@ -1088,6 +1089,14 @@
"fileLimitReachedHintFree": "Record a shorter video, or upgrade your plan to send larger files.", "fileLimitReachedHintFree": "Record a shorter video, or upgrade your plan to send larger files.",
"fileLimitReachedUpgrade": "Upgrade plan", "fileLimitReachedUpgrade": "Upgrade plan",
"contactGroupUsedByWidget": "Home screen widget", "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": "{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": { "@contactGroupUsedByWidgetSubtitle": {
"placeholders": { "placeholders": {
@ -1163,6 +1172,7 @@
"sticker": "Sticker", "sticker": "Sticker",
"stickers": "Stickers", "stickers": "Stickers",
"createSticker": "Create sticker", "createSticker": "Create sticker",
"stickerPickFromGallery": "Gallery",
"noStickersYet": "Create your first sticker from a photo.", "noStickersYet": "Create your first sticker from a photo.",
"stickerAdded": "Sticker saved", "stickerAdded": "Sticker saved",
"stickerAlreadySaved": "This sticker is already saved", "stickerAlreadySaved": "This sticker is already saved",

View file

@ -1,11 +1,14 @@
import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:convert/convert.dart'; import 'package:convert/convert.dart';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:image_picker/image_picker.dart';
import 'package:twonly/locator.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/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/data.pb.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 { class StickerService {
const StickerService._(); const StickerService._();
@ -73,10 +76,40 @@ class StickerService {
return sticker; return sticker;
} }
static Future<LocalSticker?> createFromGallery() async { /// Finds the best full-resolution local source for a stored memory. A
final image = await ImagePicker().pickImage(source: ImageSource.gallery); /// cloud-only memory is downloaded before its path is returned.
if (image == null) return null; static Future<String?> resolveMediaPath(MediaFileService media) async {
return createFromPath(image.path); 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<LocalSticker> createFromMedia(MediaFileService media) async {
final path = await resolveMediaPath(media);
if (path == null) {
throw StateError('sticker source media is unavailable');
}
return createFromPath(path);
} }
static Future<bool> saveReceived(StickerData sticker) { static Future<bool> saveReceived(StickerData sticker) {

View file

@ -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/emoji_picker/bottom_action_bar.dart';
import 'package:twonly/src/visual/components/sticker_preview.dialog.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/loader/three_rotating_dots.loader.dart';
import 'package:twonly/src/visual/views/memories/sticker_source_picker.view.dart';
class StickerPicker extends StatefulWidget { class StickerPicker extends StatefulWidget {
const StickerPicker({ const StickerPicker({
@ -32,8 +33,14 @@ class _StickerPickerState extends State<StickerPicker> {
if (_creating) return; if (_creating) return;
setState(() => _creating = true); setState(() => _creating = true);
try { try {
final sticker = await StickerService.createFromGallery(); final imagePath = await Navigator.of(context).push<String>(
if (!mounted || sticker == null) return; 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); if (widget.selectCreatedSticker) widget.onStickerSelected(sticker);
} on Object { } on Object {
if (!mounted) return; if (!mounted) return;
@ -50,86 +57,103 @@ class _StickerPickerState extends State<StickerPicker> {
final color = context.color; final color = context.color;
return ColoredBox( return ColoredBox(
color: color.surfaceContainer, color: color.surfaceContainer,
child: Column( child: StreamBuilder<List<LocalSticker>>(
children: [ stream: _stickers,
PickerBottomBar( builder: (context, snapshot) {
backgroundColor: color.surfaceContainer, final isLoading = !snapshot.hasData && !snapshot.hasError;
buttonIconColor: color.secondary, final stickers = snapshot.data ?? const <LocalSticker>[];
emojiButtonLabel: 'Emoji', final hasStickers = stickers.isNotEmpty;
stickerButtonLabel: context.lang.sticker, return Column(
showStickerButton: true, children: [
stickerSelected: true, PickerBottomBar(
onEmojiButtonPressed: widget.onEmojiPressed, backgroundColor: color.surfaceContainer,
), buttonIconColor: color.secondary,
Expanded( emojiButtonLabel: 'Emoji',
child: StreamBuilder<List<LocalSticker>>( stickerButtonLabel: context.lang.sticker,
stream: _stickers, showStickerButton: true,
builder: (context, snapshot) { stickerSelected: true,
if (!snapshot.hasData && !snapshot.hasError) { onEmojiButtonPressed: widget.onEmojiPressed,
return const Center(child: ThreeRotatingDots(size: 26)); trailing: hasStickers
} ? IconButton(
final stickers = snapshot.data ?? const <LocalSticker>[]; key: const Key('stickerPickerHeaderAdd'),
return GridView.builder( tooltip: context.lang.createSticker,
padding: const EdgeInsets.all(12), padding: const EdgeInsets.only(bottom: 2),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( onPressed: _creating ? null : _createSticker,
maxCrossAxisExtent: 104, icon: _creating
mainAxisSpacing: 8, ? const ThreeRotatingDots(size: 22)
crossAxisSpacing: 8, : Icon(
), Icons.add_photo_alternate_outlined,
itemCount: stickers.length + 1, color: color.secondary,
itemBuilder: (context, index) { ),
if (index == 0) { )
return Semantics( : null,
button: true, ),
label: context.lang.createSticker, Expanded(
child: Material( child: isLoading
color: color.surfaceContainerHigh, ? const Center(child: ThreeRotatingDots(size: 26))
borderRadius: BorderRadius.circular(16), : GridView.builder(
clipBehavior: Clip.antiAlias, padding: const EdgeInsets.all(12),
child: InkWell( gridDelegate:
onTap: _creating ? null : _createSticker, const SliverGridDelegateWithMaxCrossAxisExtent(
child: Center( maxCrossAxisExtent: 104,
child: _creating mainAxisSpacing: 8,
? const ThreeRotatingDots(size: 26) crossAxisSpacing: 8,
: Icon(
Icons.add_photo_alternate_outlined,
color: color.primary,
size: 32,
),
), ),
), itemCount: stickers.length + (hasStickers ? 0 : 1),
), itemBuilder: (context, index) {
); if (!hasStickers) {
} return Semantics(
final sticker = stickers[index - 1]; key: const Key('stickerPickerEmptyAdd'),
return Semantics( button: true,
button: true, label: context.lang.createSticker,
label: context.lang.sticker, child: Material(
child: InkWell( color: color.surfaceContainerHigh,
key: ValueKey(sticker.contentHash), borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(12), clipBehavior: Clip.antiAlias,
onTap: () => widget.onStickerSelected(sticker), child: InkWell(
onLongPress: () => showStickerPreview( onTap: _creating ? null : _createSticker,
context, child: Center(
StickerService.fromLocal(sticker), child: _creating
collectionOnly: true, ? const ThreeRotatingDots(size: 26)
), : Icon(
child: Padding( Icons.add_photo_alternate_outlined,
padding: const EdgeInsets.all(5), color: color.primary,
child: Image.memory( size: 32,
sticker.webp, ),
fit: BoxFit.contain, ),
gaplessPlayback: true, ),
), ),
), );
}
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,
),
),
),
);
},
), ),
); ),
}, ],
); );
}, },
),
),
],
), ),
); );
} }

View file

@ -671,7 +671,12 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
} }
final group = _group!; final group = _group!;
return GestureDetector( return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(), onTap: () {
FocusScope.of(context).unfocus();
if (_emojiPickerShowing) {
setState(() => _emojiPickerShowing = false);
}
},
child: Scaffold( child: Scaffold(
resizeToAvoidBottomInset: !_emojiPickerShowing, resizeToAvoidBottomInset: !_emojiPickerShowing,
appBar: AppBar( appBar: AppBar(
@ -869,6 +874,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
quotesMessage: quotesMessage, quotesMessage: quotesMessage,
textFieldFocus: textFieldFocus!, textFieldFocus: textFieldFocus!,
composing: _composing, composing: _composing,
emojiPickerShowing: _emojiPickerShowing,
onMessageSend: () { onMessageSend: () {
setState(() { setState(() {
quotesMessage = null; quotesMessage = null;

View file

@ -33,6 +33,7 @@ class MessageInput extends StatefulWidget {
required this.textFieldFocus, required this.textFieldFocus,
required this.onMessageSend, required this.onMessageSend,
required this.onEmojiVisibilityChanged, required this.onEmojiVisibilityChanged,
required this.emojiPickerShowing,
required this.composing, required this.composing,
super.key, super.key,
}); });
@ -42,6 +43,7 @@ class MessageInput extends StatefulWidget {
final Message? quotesMessage; final Message? quotesMessage;
final VoidCallback onMessageSend; final VoidCallback onMessageSend;
final ValueChanged<bool> onEmojiVisibilityChanged; final ValueChanged<bool> onEmojiVisibilityChanged;
final bool emojiPickerShowing;
/// Published so the chat-open heartbeat can stay quiet while the typing /// Published so the chat-open heartbeat can stay quiet while the typing
/// announcement is already covering this conversation. /// announcement is already covering this conversation.
@ -71,7 +73,6 @@ class _MessageInputState extends State<MessageInput>
late final TextEditingController _textFieldController; late final TextEditingController _textFieldController;
late final RecorderController recorderController; late final RecorderController recorderController;
final bool isApple = Platform.isIOS; final bool isApple = Platform.isIOS;
bool _emojiShowing = false;
bool _stickerShowing = false; bool _stickerShowing = false;
bool _switchingToSystemKeyboard = false; bool _switchingToSystemKeyboard = false;
double _emojiPickerHeight = 300; double _emojiPickerHeight = 300;
@ -226,7 +227,7 @@ class _MessageInputState extends State<MessageInput>
void _handleTextFocusChange() { void _handleTextFocusChange() {
if (widget.textFieldFocus.hasFocus && if (widget.textFieldFocus.hasFocus &&
_emojiShowing && widget.emojiPickerShowing &&
!_switchingToSystemKeyboard) { !_switchingToSystemKeyboard) {
_showSystemKeyboard(); _showSystemKeyboard();
} }
@ -246,7 +247,7 @@ class _MessageInputState extends State<MessageInput>
} }
void _toggleKeyboard() { void _toggleKeyboard() {
if (_emojiShowing) { if (widget.emojiPickerShowing) {
_showSystemKeyboard(); _showSystemKeyboard();
} else { } else {
_showEmojiPicker(); _showEmojiPicker();
@ -261,7 +262,6 @@ class _MessageInputState extends State<MessageInput>
if (keyboardHeight > 0) { if (keyboardHeight > 0) {
_emojiPickerHeight = keyboardHeight; _emojiPickerHeight = keyboardHeight;
} }
_emojiShowing = true;
_stickerShowing = false; _stickerShowing = false;
}); });
widget.onEmojiVisibilityChanged(true); widget.onEmojiVisibilityChanged(true);
@ -295,7 +295,6 @@ class _MessageInputState extends State<MessageInput>
_keyboardTransitionTimer?.cancel(); _keyboardTransitionTimer?.cancel();
setState(() { setState(() {
_switchingToSystemKeyboard = false; _switchingToSystemKeyboard = false;
_emojiShowing = false;
}); });
widget.onEmojiVisibilityChanged(false); widget.onEmojiVisibilityChanged(false);
} }
@ -490,8 +489,8 @@ class _MessageInputState extends State<MessageInput>
right: 8, right: 8,
), ),
child: FaIcon( child: FaIcon(
size: _emojiShowing ? 18 : 20, size: widget.emojiPickerShowing ? 18 : 20,
_emojiShowing widget.emojiPickerShowing
? FontAwesomeIcons.keyboard ? FontAwesomeIcons.keyboard
: FontAwesomeIcons.faceSmile, : FontAwesomeIcons.faceSmile,
), ),
@ -771,7 +770,7 @@ class _MessageInputState extends State<MessageInput>
), ),
), ),
Offstage( Offstage(
offstage: !_emojiShowing, offstage: !widget.emojiPickerShowing,
child: EmojiPicker( child: EmojiPicker(
alternateView: _stickerShowing alternateView: _stickerShowing
? StickerPicker( ? StickerPicker(

View file

@ -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/emoji_picker.bottom.dart';
import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/components/verification_badge.comp.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_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'; import 'package:twonly/src/visual/views/camera/share_image_editor_components/layer_data.dart';
class ContactGroupSettingsView extends StatefulWidget { class ContactGroupSettingsView extends StatefulWidget {
@ -67,6 +67,7 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
]; ];
late final TextEditingController _nameController; late final TextEditingController _nameController;
final TextEditingController _memberFilterController = TextEditingController();
late int _backgroundColor; late int _backgroundColor;
late int _textColor; late int _textColor;
late bool _showAsShortcut; late bool _showAsShortcut;
@ -86,6 +87,7 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
/// members are what makes those widgets able to receive anything, so the /// members are what makes those widgets able to receive anything, so the
/// group cannot be deleted while any of them is still placed. /// group cannot be deleted while any of them is still placed.
int _widgetCount = 0; int _widgetCount = 0;
bool _widgetUsageLoaded = false;
bool get _isEditing => widget.contactGroup != null; bool get _isEditing => widget.contactGroup != null;
@ -161,6 +163,7 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
unawaited(subscription.cancel()); unawaited(subscription.cancel());
} }
_nameController.dispose(); _nameController.dispose();
_memberFilterController.dispose();
super.dispose(); super.dispose();
} }
@ -265,7 +268,70 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
.where((placed) => placed.contactGroupIds.contains(contactGroupId)) .where((placed) => placed.contactGroupIds.contains(contactGroupId))
.length; .length;
if (!mounted) return; if (!mounted) return;
setState(() => _widgetCount = count); setState(() {
_widgetCount = count;
_widgetUsageLoaded = true;
});
}
Future<void> _showWidgetTutorial() async {
final isIOS = Theme.of(context).platform == TargetPlatform.iOS;
await showDialog<void>(
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<void> _delete() async { Future<void> _delete() async {
@ -365,6 +431,7 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
Widget _nameEditor() { Widget _nameEditor() {
final hasBackground = contactGroupHasBackground(_backgroundColor); final hasBackground = contactGroupHasBackground(_backgroundColor);
final fontSize = contactGroupFontSize(13, _backgroundColor); final fontSize = contactGroupFontSize(13, _backgroundColor);
final placeholder = context.lang.contactGroupNamePlaceholder;
final style = TextStyle( final style = TextStyle(
color: Color(_textColor), color: Color(_textColor),
fontSize: fontSize, fontSize: fontSize,
@ -373,7 +440,10 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
// The field has no intrinsic width, so measure the text to let the badge // The field has no intrinsic width, so measure the text to let the badge
// hug its content just like the rendered label does. // hug its content just like the rendered label does.
final painter = TextPainter( 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), textDirection: Directionality.of(context),
textScaler: MediaQuery.textScalerOf(context), textScaler: MediaQuery.textScalerOf(context),
)..layout(); )..layout();
@ -400,7 +470,11 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
textAlign: TextAlign.center, textAlign: TextAlign.center,
textCapitalization: TextCapitalization.words, textCapitalization: TextCapitalization.words,
style: style, style: style,
decoration: const InputDecoration( decoration: InputDecoration(
hintText: placeholder,
hintStyle: style.copyWith(
color: style.color?.withValues(alpha: 0.65),
),
border: InputBorder.none, border: InputBorder.none,
isDense: true, isDense: true,
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
@ -532,6 +606,25 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
value: _showAsShortcut, value: _showAsShortcut,
onChanged: (value) => setState(() => _showAsShortcut = value), 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( SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: Text(context.lang.contactGroupShareStories), title: Text(context.lang.contactGroupShareStories),
@ -539,30 +632,25 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
value: _shareStories, value: _shareStories,
onChanged: (value) => setState(() => _shareStories = value), onChanged: (value) => setState(() => _shareStories = value),
), ),
if (_showAsShortcut) if (_isEditing && _widgetUsageLoaded)
ListTile( ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: Text(context.lang.selectEmoji), leading: Icon(
trailing: Text( Icons.widgets_outlined,
_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,
color: context.color.primary, 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), const Divider(height: 40),
Text( Text(
@ -570,12 +658,13 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
const SizedBox(height: 12), 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), onChanged: (value) => setState(() => _memberFilter = value),
decoration: getInputDecoration(
context,
context.lang.shareImageSearchAllContacts,
),
), ),
for (final entry in members) _memberTile(entry), for (final entry in members) _memberTile(entry),
], ],

View file

@ -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/flame_counter.comp.dart';
import 'package:twonly/src/visual/components/verification_badge.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/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_button.element.dart';
import 'package:twonly/src/visual/elements/my_input.element.dart';
class GroupCreateSelectGroupNameView extends StatefulWidget { class GroupCreateSelectGroupNameView extends StatefulWidget {
const GroupCreateSelectGroupNameView({ const GroupCreateSelectGroupNameView({
@ -101,16 +101,11 @@ class _GroupCreateSelectGroupNameViewState
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 10), padding: const EdgeInsets.symmetric(horizontal: 10),
child: TextField( child: MyInput(
onChanged: (_) async {
setState(() {});
},
autofocus: true,
controller: textFieldGroupName, controller: textFieldGroupName,
decoration: getInputDecoration( autofocus: true,
context, hintText: context.lang.groupNameInput,
context.lang.groupNameInput, onChanged: (_) => setState(() {}),
),
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),

View file

@ -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/snackbar.dart';
import 'package:twonly/src/visual/components/verification_badge.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/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/contact_chip.element.dart';
import 'package:twonly/src/visual/elements/my_button.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'; import 'package:twonly/src/visual/views/groups/group_create_select_group_name.view.dart';
class GroupCreateSelectMembersView extends StatefulWidget { class GroupCreateSelectMembersView extends StatefulWidget {
@ -162,15 +162,13 @@ class _StartNewChatView extends State<GroupCreateSelectMembersView> {
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 10), padding: const EdgeInsets.symmetric(horizontal: 10),
child: TextField( child: MyInput(
onChanged: (_) async {
await filterUsers();
},
controller: searchUserName, controller: searchUserName,
decoration: getInputDecoration( dense: true,
context, fontWeight: FontWeight.normal,
context.lang.shareImageSearchAllContacts, hintText: context.lang.shareImageSearchAllContacts,
), prefixIcon: const Icon(Icons.search, size: 20),
onChanged: (_) => unawaited(filterUsers()),
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),

View file

@ -9,9 +9,12 @@ class SynchronizedViewerActionsToolbarComp extends StatelessWidget {
required this.onExport, required this.onExport,
required this.onToggleFavorite, required this.onToggleFavorite,
required this.onDelete, required this.onDelete,
required this.showCreateStickerButton,
required this.onCreateSticker,
this.showStoreButton = false, this.showStoreButton = false,
this.onStore, this.onStore,
this.isImageSaving = false, this.isImageSaving = false,
this.isCreatingSticker = false,
super.key, super.key,
}); });
@ -20,9 +23,12 @@ class SynchronizedViewerActionsToolbarComp extends StatelessWidget {
final VoidCallback onExport; final VoidCallback onExport;
final VoidCallback onToggleFavorite; final VoidCallback onToggleFavorite;
final VoidCallback onDelete; final VoidCallback onDelete;
final bool showCreateStickerButton;
final VoidCallback onCreateSticker;
final bool showStoreButton; final bool showStoreButton;
final VoidCallback? onStore; final VoidCallback? onStore;
final bool isImageSaving; final bool isImageSaving;
final bool isCreatingSticker;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -80,6 +86,27 @@ class SynchronizedViewerActionsToolbarComp extends StatelessWidget {
: context.lang.galleryActionFavorite, : context.lang.galleryActionFavorite,
onTap: onToggleFavorite, 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( _ToolbarAction(
icon: Icon( icon: Icon(
Icons.delete, Icons.delete,

View file

@ -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<StickerSourcePickerView> createState() =>
_StickerSourcePickerViewState();
}
class _StickerSourcePickerViewState extends State<StickerSourcePickerView> {
late final MemoriesService _memories = MemoriesService();
String? _resolvingMediaId;
@override
void dispose() {
_memories.dispose();
super.dispose();
}
Future<void> _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<void> _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<MemoriesState>(
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)),
),
),
],
);
},
),
);
}
}

View file

@ -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/database/twonly.db.dart';
import 'package:twonly/src/model/memory_item.model.dart'; import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.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/log.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/delete_memories_dialog.comp.dart'; import 'package:twonly/src/visual/components/delete_memories_dialog.comp.dart';
@ -41,6 +42,7 @@ class _SynchronizedImageViewerScreenState
final Set<String> _favoritedMediaIds = {}; final Set<String> _favoritedMediaIds = {};
bool _isSaving = false; bool _isSaving = false;
bool _isCreatingSticker = false;
final Set<String> _storedMediaIds = {}; final Set<String> _storedMediaIds = {};
final Set<String> _precachedMediaIds = {}; final Set<String> _precachedMediaIds = {};
@ -95,6 +97,26 @@ class _SynchronizedImageViewerScreenState
} }
} }
Future<void> _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<void> _toggleFavorite(String mediaId) async { Future<void> _toggleFavorite(String mediaId) async {
final wasFavorite = _favoritedMediaIds.contains(mediaId); final wasFavorite = _favoritedMediaIds.contains(mediaId);
final isFavoriteNow = !wasFavorite; final isFavoriteNow = !wasFavorite;
@ -365,6 +387,11 @@ class _SynchronizedImageViewerScreenState
onExport: _exportFile, onExport: _exportFile,
onToggleFavorite: () => onToggleFavorite: () =>
_toggleFavorite(currentMediaId), _toggleFavorite(currentMediaId),
showCreateStickerButton:
orgMediaService.mediaFile.type ==
MediaType.image,
onCreateSticker: _createSticker,
isCreatingSticker: _isCreatingSticker,
onDelete: _deleteFile, onDelete: _deleteFile,
showStoreButton: !_storedMediaIds.contains( showStoreButton: !_storedMediaIds.contains(
currentMediaId, currentMediaId,

View file

@ -2,10 +2,12 @@ import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:drift/drift.dart' show DatabaseConnection;
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/localization/generated/app_localizations.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/elements/my_button.element.dart';
import 'package:twonly/src/visual/themes/light.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/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 // A small valid image for exercising image widgets; protocol codec validation
// and real transparent WebP inference are covered by the Rust sticker tests. // and real transparent WebP inference are covered by the Rust sticker tests.
@ -50,10 +53,17 @@ Future<void> _settle(WidgetTester tester) async {
void main() { void main() {
late TwonlyDB database; late TwonlyDB database;
setUpAll(AppEnvironment.initTesting);
setUp(() async { setUp(() async {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
await locator.reset(); await locator.reset();
database = TwonlyDB(NativeDatabase.memory()); database = TwonlyDB.forTesting(
DatabaseConnection(
NativeDatabase.memory(),
closeStreamsSynchronously: true,
),
);
locator.registerSingleton<TwonlyDB>(database); locator.registerSingleton<TwonlyDB>(database);
await database.customSelect('SELECT 1').get(); 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<void>.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', ( testWidgets('chat tap previews without saving; buttons save then remove', (
tester, tester,
) async { ) async {

View file

@ -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);
});
}