mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-10-09 02:54:14 +00:00
fix smaller layout issues and add sticker creation button to stored images
This commit is contained in:
parent
76018bd780
commit
4337db58be
19 changed files with 798 additions and 143 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 => 'أنشئ ملصقك الأول من صورة.';
|
||||
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
|
||||
|
|
|
|||
|
|
@ -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": "هذا الملصق محفوظ بالفعل",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<LocalSticker?> 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<String?> 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<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) {
|
||||
|
|
|
|||
|
|
@ -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<StickerPicker> {
|
|||
if (_creating) return;
|
||||
setState(() => _creating = true);
|
||||
try {
|
||||
final sticker = await StickerService.createFromGallery();
|
||||
if (!mounted || sticker == null) return;
|
||||
final imagePath = await Navigator.of(context).push<String>(
|
||||
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<StickerPicker> {
|
|||
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<List<LocalSticker>>(
|
||||
stream: _stickers,
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData && !snapshot.hasError) {
|
||||
return const Center(child: ThreeRotatingDots(size: 26));
|
||||
}
|
||||
final stickers = snapshot.data ?? const <LocalSticker>[];
|
||||
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<List<LocalSticker>>(
|
||||
stream: _stickers,
|
||||
builder: (context, snapshot) {
|
||||
final isLoading = !snapshot.hasData && !snapshot.hasError;
|
||||
final stickers = snapshot.data ?? const <LocalSticker>[];
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -671,7 +671,12 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
}
|
||||
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<ChatMessagesView>
|
|||
quotesMessage: quotesMessage,
|
||||
textFieldFocus: textFieldFocus!,
|
||||
composing: _composing,
|
||||
emojiPickerShowing: _emojiPickerShowing,
|
||||
onMessageSend: () {
|
||||
setState(() {
|
||||
quotesMessage = null;
|
||||
|
|
|
|||
|
|
@ -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<bool> 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<MessageInput>
|
|||
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<MessageInput>
|
|||
|
||||
void _handleTextFocusChange() {
|
||||
if (widget.textFieldFocus.hasFocus &&
|
||||
_emojiShowing &&
|
||||
widget.emojiPickerShowing &&
|
||||
!_switchingToSystemKeyboard) {
|
||||
_showSystemKeyboard();
|
||||
}
|
||||
|
|
@ -246,7 +247,7 @@ class _MessageInputState extends State<MessageInput>
|
|||
}
|
||||
|
||||
void _toggleKeyboard() {
|
||||
if (_emojiShowing) {
|
||||
if (widget.emojiPickerShowing) {
|
||||
_showSystemKeyboard();
|
||||
} else {
|
||||
_showEmojiPicker();
|
||||
|
|
@ -261,7 +262,6 @@ class _MessageInputState extends State<MessageInput>
|
|||
if (keyboardHeight > 0) {
|
||||
_emojiPickerHeight = keyboardHeight;
|
||||
}
|
||||
_emojiShowing = true;
|
||||
_stickerShowing = false;
|
||||
});
|
||||
widget.onEmojiVisibilityChanged(true);
|
||||
|
|
@ -295,7 +295,6 @@ class _MessageInputState extends State<MessageInput>
|
|||
_keyboardTransitionTimer?.cancel();
|
||||
setState(() {
|
||||
_switchingToSystemKeyboard = false;
|
||||
_emojiShowing = false;
|
||||
});
|
||||
widget.onEmojiVisibilityChanged(false);
|
||||
}
|
||||
|
|
@ -490,8 +489,8 @@ class _MessageInputState extends State<MessageInput>
|
|||
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<MessageInput>
|
|||
),
|
||||
),
|
||||
Offstage(
|
||||
offstage: !_emojiShowing,
|
||||
offstage: !widget.emojiPickerShowing,
|
||||
child: EmojiPicker(
|
||||
alternateView: _stickerShowing
|
||||
? StickerPicker(
|
||||
|
|
|
|||
|
|
@ -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<ContactGroupSettingsView> {
|
|||
];
|
||||
|
||||
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<ContactGroupSettingsView> {
|
|||
/// 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<ContactGroupSettingsView> {
|
|||
unawaited(subscription.cancel());
|
||||
}
|
||||
_nameController.dispose();
|
||||
_memberFilterController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -265,7 +268,70 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
|
|||
.where((placed) => placed.contactGroupIds.contains(contactGroupId))
|
||||
.length;
|
||||
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 {
|
||||
|
|
@ -365,6 +431,7 @@ class _ContactGroupSettingsViewState extends State<ContactGroupSettingsView> {
|
|||
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<ContactGroupSettingsView> {
|
|||
// 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<ContactGroupSettingsView> {
|
|||
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<ContactGroupSettingsView> {
|
|||
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<ContactGroupSettingsView> {
|
|||
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<ContactGroupSettingsView> {
|
|||
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),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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<GroupCreateSelectMembersView> {
|
|||
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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
136
lib/src/visual/views/memories/sticker_source_picker.view.dart
Normal file
136
lib/src/visual/views/memories/sticker_source_picker.view.dart
Normal 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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> _favoritedMediaIds = {};
|
||||
bool _isSaving = false;
|
||||
bool _isCreatingSticker = false;
|
||||
final Set<String> _storedMediaIds = {};
|
||||
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 {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<void> _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<TwonlyDB>(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<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', (
|
||||
tester,
|
||||
) async {
|
||||
|
|
|
|||
53
test/visual/memories/sticker_viewer_action_test.dart
Normal file
53
test/visual/memories/sticker_viewer_action_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue