fix plans and exception

This commit is contained in:
otsmr 2026-10-06 07:24:14 +02:00
parent 76a9455c7c
commit 7d17ffba1f
12 changed files with 188 additions and 93 deletions

View file

@ -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:

View file

@ -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 => 'ميزات إضافية (قريبًا)';

View file

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

View file

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

View file

@ -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": "فتح",

View file

@ -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",

View file

@ -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",

View file

@ -184,10 +184,14 @@ class EmojiPicker extends StatefulWidget {
/// EmojiPickerState
class EmojiPickerState extends State<EmojiPicker> {
final List<CategoryEmoji> _categoryEmoji = List.empty(growable: true);
List<CategoryEmoji> _categoryEmoji = List.empty(growable: true);
List<RecentEmoji> _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<EmojiPicker> {
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<EmojiPicker> {
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<EmojiPicker> {
// Initialize emoji data
Future<void> _updateEmojis() async {
_categoryEmoji.clear();
final loadGeneration = ++_emojiLoadGeneration;
final config = widget.config;
final categoryEmoji = <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>
recentEmoji = futureOrRecent is List<RecentEmoji>
? 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<CategoryEmoji>
? futureOrCategories
: await futureOrCategories,
);
} else {
_categoryEmoji.addAll(data);
categoryEmoji.addAll(data);
}
if (!mounted || loadGeneration != _emojiLoadGeneration) {
return;
}
_recentEmoji = recentEmoji;
_categoryEmoji = categoryEmoji;
_state = EmojiViewState(
_categoryEmoji,
_onEmojiSelected,

View file

@ -22,7 +22,7 @@ class DefaultEmojiPickerView extends EmojiPickerView {
}
class _DefaultEmojiPickerViewState extends State<DefaultEmojiPickerView>
with SingleTickerProviderStateMixin, SkinToneOverlayStateMixin {
with TickerProviderStateMixin, SkinToneOverlayStateMixin {
late TabController _tabController;
late PageController _legacyPageController;
final _scrollController = ScrollController();
@ -39,12 +39,65 @@ class _DefaultEmojiPickerViewState extends State<DefaultEmojiPickerView>
@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<DefaultEmojiPickerView>
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<DefaultEmojiPickerView>
_onCategoryNavigationChanged,
);
closeSkinToneOverlay();
_tabController.dispose();
_legacyPageController.dispose();
_scrollController.removeListener(_onEmojiScroll);
_scrollController.dispose();

View file

@ -368,21 +368,19 @@ class _PlanCardState extends State<PlanCard> {
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,
];

View file

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

View file

@ -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<CategoryEmoji> _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<TabBar>(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<TabBar>(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<TabBar>(find.byType(TabBar));
expect(tabBar.controller!.length, 17);
expect(tabBar.tabs.length, 17);
});
testWidgets('Drag handle grows picker from its configured height', (
tester,
) async {