diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 0d3fbcdc..88f5ec40 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -1408,12 +1408,6 @@ abstract class AppLocalizations { /// **'Monthly'** String get monthly; - /// No description provided for @proFeature1. - /// - /// In en, this message translates to: - /// **'Unlimited media file uploads'** - String get proFeature1; - /// No description provided for @proFeature2. /// /// In en, this message translates to: @@ -1432,12 +1426,6 @@ abstract class AppLocalizations { /// **'Restore flames'** String get proFeature4; - /// No description provided for @familyFeature1. - /// - /// In en, this message translates to: - /// **'Unlimited media file uploads'** - String get familyFeature1; - /// No description provided for @familyFeature2. /// /// In en, this message translates to: @@ -1462,12 +1450,6 @@ abstract class AppLocalizations { /// **'10 Media file uploads per day'** String get freeFeature1; - /// No description provided for @plusFeature1. - /// - /// In en, this message translates to: - /// **'Unlimited media file uploads'** - String get plusFeature1; - /// No description provided for @plusFeature2. /// /// In en, this message translates to: diff --git a/lib/src/localization/generated/app_localizations_ar.dart b/lib/src/localization/generated/app_localizations_ar.dart index a7d806ae..fa1f32da 100644 --- a/lib/src/localization/generated/app_localizations_ar.dart +++ b/lib/src/localization/generated/app_localizations_ar.dart @@ -756,9 +756,6 @@ class AppLocalizationsAr extends AppLocalizations { @override String get monthly => 'شهريًا'; - @override - String get proFeature1 => 'رفع غير محدود لملفات الوسائط'; - @override String get proFeature2 => 'مستخدم Plus إضافي واحد'; @@ -768,9 +765,6 @@ class AppLocalizationsAr extends AppLocalizations { @override String get proFeature4 => 'استعادة الشعلات'; - @override - String get familyFeature1 => 'رفع غير محدود لملفات الوسائط'; - @override String get familyFeature2 => '4 مستخدمين Plus إضافيين'; @@ -783,9 +777,6 @@ class AppLocalizationsAr extends AppLocalizations { @override String get freeFeature1 => 'رفع 10 ملفات وسائط يوميًا'; - @override - String get plusFeature1 => 'رفع غير محدود لملفات الوسائط'; - @override String get plusFeature2 => 'ميزات إضافية (قريبًا)'; diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index f9e66359..8064ad3c 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -754,9 +754,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get monthly => 'Monatlich'; - @override - String get proFeature1 => 'Unbegrenzte Medien-Datei-Uploads'; - @override String get proFeature2 => '1 zusätzlicher Plus Benutzer'; @@ -766,9 +763,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get proFeature4 => 'Flammen wiederherstellen'; - @override - String get familyFeature1 => 'Unbegrenzte Medien-Datei-Uploads'; - @override String get familyFeature2 => '4 zusätzliche Plus Benutzer'; @@ -781,9 +775,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get freeFeature1 => '10 Medien-Datei-Uploads pro Tag'; - @override - String get plusFeature1 => 'Unbegrenzte Medien-Datei-Uploads'; - @override String get plusFeature2 => 'Zusatzfunktionen (coming-soon)'; diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index f2c7444f..fb15dd68 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -750,9 +750,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get monthly => 'Monthly'; - @override - String get proFeature1 => 'Unlimited media file uploads'; - @override String get proFeature2 => '1 additional Plus user'; @@ -762,9 +759,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get proFeature4 => 'Restore flames'; - @override - String get familyFeature1 => 'Unlimited media file uploads'; - @override String get familyFeature2 => '4 additional Plus user'; @@ -777,9 +771,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get freeFeature1 => '10 Media file uploads per day'; - @override - String get plusFeature1 => 'Unlimited media file uploads'; - @override String get plusFeature2 => 'Additional features (coming-soon)'; diff --git a/lib/src/localization/translations/ar.arb b/lib/src/localization/translations/ar.arb index 1c4ea36f..efd78879 100644 --- a/lib/src/localization/translations/ar.arb +++ b/lib/src/localization/translations/ar.arb @@ -218,16 +218,13 @@ "yearly": "سنويًا", "month": "شهر", "monthly": "شهريًا", - "proFeature1": "رفع غير محدود لملفات الوسائط", "proFeature2": "مستخدم Plus إضافي واحد", "proFeature3": "مساحة 25 GB لتخزين الذكريات", "proFeature4": "استعادة الشعلات", - "familyFeature1": "رفع غير محدود لملفات الوسائط", "familyFeature2": "4 مستخدمين Plus إضافيين", "familyFeature3": "مساحة 50 GB لتخزين الذكريات", "familyFeature4": "استعادة الشعلات", "freeFeature1": "رفع 10 ملفات وسائط يوميًا", - "plusFeature1": "رفع غير محدود لملفات الوسائط", "plusFeature2": "ميزات إضافية (قريبًا)", "manageAdditionalUsers": "إدارة المستخدمين الإضافيين", "open": "فتح", diff --git a/lib/src/localization/translations/de.arb b/lib/src/localization/translations/de.arb index 4ce34d15..6f11543f 100644 --- a/lib/src/localization/translations/de.arb +++ b/lib/src/localization/translations/de.arb @@ -255,7 +255,6 @@ "errorPlanNotAllowed": "Dieses Feature ist in deinem aktuellen Plan nicht verfügbar.", "errorVoucherInvalid": "Der eingegebene Gutschein-Code ist nicht gültig.", "errorPlanUpgradeNotYearly": "Das Upgrade des Plans muss jährlich bezahlt werden, da der aktuelle Plan ebenfalls jährlich abgerechnet wird.", - "proFeature1": "Unbegrenzte Medien-Datei-Uploads", "proFeature2": "1 zusätzlicher Plus Benutzer", "proFeature3": "25GB Memories Speicher", "proFeature4": "Flammen wiederherstellen", @@ -263,12 +262,10 @@ "month": "Monat", "yearly": "Jährlich", "monthly": "Monatlich", - "familyFeature1": "Unbegrenzte Medien-Datei-Uploads", "familyFeature2": "4 zusätzliche Plus Benutzer", "familyFeature3": "50GB Memories Speicher", "familyFeature4": "Flammen wiederherstellen", "freeFeature1": "10 Medien-Datei-Uploads pro Tag", - "plusFeature1": "Unbegrenzte Medien-Datei-Uploads", "plusFeature2": "Zusatzfunktionen (coming-soon)", "manageAdditionalUsers": "Zusätzliche Benutzer verwalten", "open": "Offene", diff --git a/lib/src/localization/translations/en.arb b/lib/src/localization/translations/en.arb index 0479b7bb..675182fc 100644 --- a/lib/src/localization/translations/en.arb +++ b/lib/src/localization/translations/en.arb @@ -269,16 +269,13 @@ "yearly": "Yearly", "month": "month", "monthly": "Monthly", - "proFeature1": "Unlimited media file uploads", "proFeature2": "1 additional Plus user", "proFeature3": "25GB Memories storage", "proFeature4": "Restore flames", - "familyFeature1": "Unlimited media file uploads", "familyFeature2": "4 additional Plus user", "familyFeature3": "50GB Memories storage", "familyFeature4": "Restore flames", "freeFeature1": "10 Media file uploads per day", - "plusFeature1": "Unlimited media file uploads", "plusFeature2": "Additional features (coming-soon)", "manageAdditionalUsers": "Manage additional users", "open": "Open", diff --git a/lib/src/visual/components/emoji_picker/emoji_picker_widget.dart b/lib/src/visual/components/emoji_picker/emoji_picker_widget.dart index a9b0ad61..4164bb72 100644 --- a/lib/src/visual/components/emoji_picker/emoji_picker_widget.dart +++ b/lib/src/visual/components/emoji_picker/emoji_picker_widget.dart @@ -184,10 +184,14 @@ class EmojiPicker extends StatefulWidget { /// EmojiPickerState class EmojiPickerState extends State { - final List _categoryEmoji = List.empty(growable: true); + List _categoryEmoji = List.empty(growable: true); List _recentEmoji = List.empty(growable: true); late EmojiViewState _state; + // Identifies the newest asynchronous emoji load so an older load cannot + // replace data produced for a more recent config. + int _emojiLoadGeneration = 0; + // Prevent emojis to be reloaded with every build bool _loaded = false; @@ -243,8 +247,10 @@ class EmojiPickerState extends State { if (oldWidget.config.height != widget.config.height) { _currentPickerHeight = widget.config.height; } - if (oldWidget.config != widget.config) { - // Config changed - rebuild EmojiPickerView completely + if (_emojiDataConfigChanged(oldWidget.config, widget.config)) { + // Only reload data when a setting that affects the category contents + // changes. Visual config and callback changes already flow through the + // normal widget rebuild and must not start overlapping async loads. _loaded = false; _updateEmojis(); } @@ -264,6 +270,15 @@ class EmojiPickerState extends State { super.didUpdateWidget(oldWidget); } + bool _emojiDataConfigChanged(Config oldConfig, Config newConfig) { + return oldConfig.locale != newConfig.locale || + oldConfig.emojiSet != newConfig.emojiSet || + oldConfig.checkPlatformCompatibility != + newConfig.checkPlatformCompatibility || + oldConfig.categoryViewConfig.recentTabBehavior != + newConfig.categoryViewConfig.recentTabBehavior; + } + @override Widget build(BuildContext context) { if (widget.alternateView != null) { @@ -445,33 +460,44 @@ class EmojiPickerState extends State { // Initialize emoji data Future _updateEmojis() async { - _categoryEmoji.clear(); + final loadGeneration = ++_emojiLoadGeneration; + final config = widget.config; + final categoryEmoji = []; + var recentEmoji = _recentEmoji; + if ([ RecentTabBehavior.RECENT, RecentTabBehavior.POPULAR, - ].contains(widget.config.categoryViewConfig.recentTabBehavior)) { + ].contains(config.categoryViewConfig.recentTabBehavior)) { final futureOrRecent = _emojiPickerInternalUtils.getRecentEmojis(); - _recentEmoji = futureOrRecent is List + recentEmoji = futureOrRecent is List ? futureOrRecent : await futureOrRecent; - final recentEmojiMap = _recentEmoji.map((e) => e.emoji).toList(); - _categoryEmoji.add(CategoryEmoji(Category.RECENT, recentEmojiMap)); + final recentEmojiMap = recentEmoji.map((e) => e.emoji).toList(); + categoryEmoji.add(CategoryEmoji(Category.RECENT, recentEmojiMap)); } final data = - widget.config.emojiSet?.call(widget.config.locale) ?? - getDefaultEmojiLocale(widget.config.locale); - if (widget.config.checkPlatformCompatibility) { + config.emojiSet?.call(config.locale) ?? + getDefaultEmojiLocale(config.locale); + if (config.checkPlatformCompatibility) { final futureOrCategories = _emojiPickerInternalUtils.filterUnsupported( data, ); - _categoryEmoji.addAll( + categoryEmoji.addAll( futureOrCategories is List ? futureOrCategories : await futureOrCategories, ); } else { - _categoryEmoji.addAll(data); + categoryEmoji.addAll(data); } + + if (!mounted || loadGeneration != _emojiLoadGeneration) { + return; + } + + _recentEmoji = recentEmoji; + _categoryEmoji = categoryEmoji; _state = EmojiViewState( _categoryEmoji, _onEmojiSelected, diff --git a/lib/src/visual/components/emoji_picker/emoji_view/default_emoji_picker_view.dart b/lib/src/visual/components/emoji_picker/emoji_view/default_emoji_picker_view.dart index 64eb62ce..f7771525 100644 --- a/lib/src/visual/components/emoji_picker/emoji_view/default_emoji_picker_view.dart +++ b/lib/src/visual/components/emoji_picker/emoji_view/default_emoji_picker_view.dart @@ -22,7 +22,7 @@ class DefaultEmojiPickerView extends EmojiPickerView { } class _DefaultEmojiPickerViewState extends State - with SingleTickerProviderStateMixin, SkinToneOverlayStateMixin { + with TickerProviderStateMixin, SkinToneOverlayStateMixin { late TabController _tabController; late PageController _legacyPageController; final _scrollController = ScrollController(); @@ -39,12 +39,65 @@ class _DefaultEmojiPickerViewState extends State @override void initState() { - // Use controller's current category if available, - // otherwise use config's initCategory + final initCategory = _createCategoryControllers(); + _scrollController.addListener(_onEmojiScroll); + + // Listen to programmatic category changes from controller + widget.state.categoryNavigationNotifier.addListener( + _onCategoryNavigationChanged, + ); + + _loadRememberedSkinTone(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _scrollToCategory(initCategory, animate: false); + } + }); + + super.initState(); + } + + @override + void didUpdateWidget(covariant DefaultEmojiPickerView oldWidget) { + super.didUpdateWidget(oldWidget); + if (identical(oldWidget.state, widget.state)) { + return; + } + + final previousCategory = + _activeCategoryIndex < oldWidget.state.categoryEmoji.length + ? oldWidget.state.categoryEmoji[_activeCategoryIndex].category + : null; + oldWidget.state.categoryNavigationNotifier.removeListener( + _onCategoryNavigationChanged, + ); + closeSkinToneOverlay(); + _tabController.dispose(); + _legacyPageController.dispose(); + _categoryOffsets = const []; + _isNavigatingToCategory = false; + + final initCategory = _createCategoryControllers( + preferredCategory: previousCategory, + ); + widget.state.categoryNavigationNotifier.addListener( + _onCategoryNavigationChanged, + ); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _scrollToCategory(initCategory, animate: false); + } + }); + } + + int _createCategoryControllers({Category? preferredCategory}) { + // Prefer an externally controlled category, then preserve the current + // category across data reloads, and finally fall back to the config. final targetCategory = widget.state.currentCategory ?? + preferredCategory ?? widget.config.categoryViewConfig.initCategory; - var initCategory = widget.state.categoryEmoji.indexWhere( (element) => element.category == targetCategory, ); @@ -62,22 +115,7 @@ class _DefaultEmojiPickerViewState extends State initialPage: initCategory, onPageRequested: _scrollToCategory, ); - _scrollController.addListener(_onEmojiScroll); - - // Listen to programmatic category changes from controller - widget.state.categoryNavigationNotifier.addListener( - _onCategoryNavigationChanged, - ); - - _loadRememberedSkinTone(); - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _scrollToCategory(initCategory, animate: false); - } - }); - - super.initState(); + return initCategory; } void _loadRememberedSkinTone() { @@ -110,6 +148,7 @@ class _DefaultEmojiPickerViewState extends State _onCategoryNavigationChanged, ); closeSkinToneOverlay(); + _tabController.dispose(); _legacyPageController.dispose(); _scrollController.removeListener(_onEmojiScroll); _scrollController.dispose(); diff --git a/lib/src/visual/views/settings/subscription/subscription.view.dart b/lib/src/visual/views/settings/subscription/subscription.view.dart index 969f24eb..b2dc3740 100644 --- a/lib/src/visual/views/settings/subscription/subscription.view.dart +++ b/lib/src/visual/views/settings/subscription/subscription.view.dart @@ -368,21 +368,19 @@ class _PlanCardState extends State { case 'Free': features = [context.lang.freeFeature1]; case 'Plus': - features = [context.lang.plusFeature1]; //, context.lang.plusFeature2]; + features = []; case 'Tester': case 'Pro': features = [ - context.lang.proFeature1, - context.lang.proFeature2, context.lang.proFeature3, + context.lang.proFeature2, context.lang.proFeature4, context.lang.subscriptionAllApps, ]; case 'Family': features = [ - context.lang.familyFeature1, - context.lang.familyFeature2, context.lang.familyFeature3, + context.lang.familyFeature2, context.lang.familyFeature4, context.lang.subscriptionAllApps, ]; diff --git a/rust/src/services/notifications.rs b/rust/src/services/notifications.rs index af7c9cca..87ace8aa 100644 --- a/rust/src/services/notifications.rs +++ b/rust/src/services/notifications.rs @@ -17,6 +17,13 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, LazyLock}; +// Temporary companion guard for the server's iOS 0.6.x wake-up workaround. +// The server can bound APNs attempts, but only this device-side query can bound +// events that were already downloaded into the local notification outbox. +// Keep Android's existing batch size unchanged. +#[cfg(target_os = "ios")] +const MAX_BATCH_SIZE: i64 = 40; +#[cfg(not(target_os = "ios"))] const MAX_BATCH_SIZE: i64 = 100; const EN_ARB: &str = include_str!("../../../lib/src/localization/translations/en.arb"); const DE_ARB: &str = include_str!("../../../lib/src/localization/translations/de.arb"); diff --git a/test/visual/components/emoji_picker/emoji_picker_test.dart b/test/visual/components/emoji_picker/emoji_picker_test.dart index c24bff85..eae3c60a 100644 --- a/test/visual/components/emoji_picker/emoji_picker_test.dart +++ b/test/visual/components/emoji_picker/emoji_picker_test.dart @@ -6,9 +6,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:twonly/src/visual/components/emoji_picker/emoji_picker.dart'; import 'package:twonly/src/visual/components/emoji_picker/emoji_picker_internal_utils.dart'; +List _duplicatedEmojiSet(Locale _) => [ + ...defaultEmojiSet, + ...defaultEmojiSet, +]; + // Use for golden tests, helpful in debugging // await expectLater( // find.byType(MaterialApp), @@ -18,7 +24,10 @@ import 'package:twonly/src/visual/components/emoji_picker/emoji_picker_internal_ void main() { group('EmojiPicker Tests', () { // Caches are static and would otherwise leak between test cases. - setUp(EmojiPickerInternalUtils.resetCaches); + setUp(() { + SharedPreferences.setMockInitialValues({}); + EmojiPickerInternalUtils.resetCaches(); + }); testWidgets('Should allow user to select an emoji', ( tester, @@ -135,6 +144,76 @@ void main() { expect(scrollable.position.pixels, greaterThan(0)); }); + testWidgets('Recreates category navigation when the emoji set changes', ( + tester, + ) async { + var config = const Config(checkPlatformCompatibility: false); + late StateSetter setHarnessState; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + setHarnessState = setState; + return EmojiPicker(config: config); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + var tabBar = tester.widget(find.byType(TabBar)); + expect(tabBar.controller!.length, 9); + expect(tabBar.tabs.length, 9); + + setHarnessState(() { + config = const Config( + checkPlatformCompatibility: false, + emojiSet: _duplicatedEmojiSet, + ); + }); + await tester.pumpAndSettle(); + + tabBar = tester.widget(find.byType(TabBar)); + expect(tabBar.controller!.length, 17); + expect(tabBar.tabs.length, 17); + }); + + testWidgets('Keeps only the newest emoji load when configs overlap', ( + tester, + ) async { + var config = const Config(checkPlatformCompatibility: false); + late StateSetter setHarnessState; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + setHarnessState = setState; + return EmojiPicker(config: config); + }, + ), + ), + ), + ); + + // Change the config while the first recent-emoji read is still pending. + setHarnessState(() { + config = const Config( + checkPlatformCompatibility: false, + emojiSet: _duplicatedEmojiSet, + ); + }); + await tester.pumpAndSettle(); + + final tabBar = tester.widget(find.byType(TabBar)); + expect(tabBar.controller!.length, 17); + expect(tabBar.tabs.length, 17); + }); + testWidgets('Drag handle grows picker from its configured height', ( tester, ) async {