банеры

This commit is contained in:
Jganenokk
2026-07-27 23:36:50 +07:00
parent 9e579f45cf
commit d7e6491213
11 changed files with 583 additions and 16 deletions
+1
View File
@@ -120,6 +120,7 @@ class BannersModule {
} }
Future<void> markShown(InformerBanner banner) async { Future<void> markShown(InformerBanner banner) async {
if (_pinnedId == banner.id) return;
final now = DateTime.now().millisecondsSinceEpoch; final now = DateTime.now().millisecondsSinceEpoch;
final current = stateOf(banner.id); final current = stateOf(banner.id);
_showState[banner.id] = current.copyWith( _showState[banner.id] = current.copyWith(
+8 -3
View File
@@ -1161,12 +1161,14 @@ class ChatsModule {
final cachedAt = DateTime.now().millisecondsSinceEpoch; final cachedAt = DateTime.now().millisecondsSinceEpoch;
final id = chat['id']; final id = chat['id'];
Map<int, CachedChat> existing = const {}; Map<int, CachedChat> existing = const {};
Map<String, dynamic>? existingRow;
if (preloadedExisting != null) { if (preloadedExisting != null) {
existing = preloadedExisting; existing = preloadedExisting;
} else if (id is int) { } else if (id is int) {
final rows = await AppDatabase.loadChat(accountId, id); final rows = await AppDatabase.loadChat(accountId, id);
if (rows.isNotEmpty) { if (rows.isNotEmpty) {
existing = {id: CachedChat.fromDbRow(rows.first)}; existingRow = rows.first;
existing = {id: CachedChat.fromDbRow(existingRow)};
} }
} }
final parsed = parseChatRow( final parsed = parseChatRow(
@@ -1184,11 +1186,14 @@ class ChatsModule {
return null; return null;
} }
final ex = existing[parsed.id]; final ex = existing[parsed.id];
if (ex != null && sameChatContent(ex, parsed)) { final listState = !inList ? 0 : (chat['status'] == 'HIDDEN' ? 2 : 1);
final membershipUnchanged =
existingRow == null || existingRow['in_list'] == listState;
if (ex != null && sameChatContent(ex, parsed) && membershipUnchanged) {
return parsed; return parsed;
} }
final row = parsed.toDbRow(); final row = parsed.toDbRow();
row['in_list'] = !inList ? 0 : (chat['status'] == 'HIDDEN' ? 2 : 1); row['in_list'] = listState;
await AppDatabase.saveChats([row]); await AppDatabase.saveChats([row]);
_bump(); _bump();
return parsed; return parsed;
+10
View File
@@ -727,6 +727,16 @@ class AppDatabase {
); );
} }
static bool chatRowIsInList(Map<String, dynamic> row) {
final value = row['in_list'];
return value is! int || value != 0;
}
static Future<bool> isChatInList(int accountId, int chatId) async {
final rows = await loadChat(accountId, chatId);
return rows.isNotEmpty && chatRowIsInList(rows.first);
}
static Future<List<Map<String, dynamic>>> loadChats( static Future<List<Map<String, dynamic>>> loadChats(
int accountId, { int accountId, {
bool includeHidden = false, bool includeHidden = false,
@@ -348,6 +348,17 @@ class ChatController extends ChangeNotifier {
required void Function() onSenderNames, required void Function() onSenderNames,
}) async { }) async {
final onlyVisible = !KometSettings.viewDeleted.value; final onlyVisible = !KometSettings.viewDeleted.value;
final cachedRows = await AppDatabase.loadChat(myId, chatId);
final preview =
cachedRows.isEmpty || !AppDatabase.chatRowIsInList(cachedRows.first);
if (preview) {
onPreview();
if (cachedRows.isEmpty) {
await chats.ensureChatCached(api, myId, chatId);
}
await chats.subscribeChat(api, chatId);
}
final fullDecoded = await loadInitialFromDb(onlyVisible: onlyVisible); final fullDecoded = await loadInitialFromDb(onlyVisible: onlyVisible);
if (isMounted()) { if (isMounted()) {
onApplyMerged(fullDecoded); onApplyMerged(fullDecoded);
@@ -362,12 +373,6 @@ class ChatController extends ChangeNotifier {
} }
try { try {
final cachedRows = await AppDatabase.loadChat(myId, chatId);
if (cachedRows.isEmpty) {
onPreview();
await chats.ensureChatCached(api, myId, chatId);
await chats.subscribeChat(api, chatId);
}
final serverMessages = await messagesModule.fetchHistory(myId, chatId); final serverMessages = await messagesModule.fetchHistory(myId, chatId);
chats.markHistoryFetched(chatId); chats.markHistoryFetched(chatId);
if (KometSettings.viewDeleted.value) { if (KometSettings.viewDeleted.value) {
@@ -23,10 +23,14 @@ import '../../widgets/swipe_route.dart';
import '../../widgets/sliding_pill_nav.dart'; import '../../widgets/sliding_pill_nav.dart';
import '../../widgets/springy_tap.dart'; import '../../widgets/springy_tap.dart';
import '../../widgets/formatted_message_text.dart'; import '../../widgets/formatted_message_text.dart';
import '../../widgets/informer_banner_tile.dart';
import '../../../core/utils/format.dart'; import '../../../core/utils/format.dart';
import '../../../core/utils/download_history.dart'; import '../../../core/utils/download_history.dart';
import '../../../core/utils/link_opener.dart';
import '../../../core/utils/text_format.dart'; import '../../../core/utils/text_format.dart';
import '../../../core/utils/update_checker.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import '../../../models/informer_banner.dart';
import '../calls/calls_tab.dart'; import '../calls/calls_tab.dart';
import '../contacts/contacts_tab.dart'; import '../contacts/contacts_tab.dart';
@@ -61,8 +65,16 @@ import '../../../core/storage/chat_encryption_store.dart';
import '../../../core/storage/token_storage.dart'; import '../../../core/storage/token_storage.dart';
import '../../../core/storage/chat_activity_store.dart'; import '../../../core/storage/chat_activity_store.dart';
import '../../../main.dart' import '../../../main.dart'
show accountModule, api, messagesModule, storiesModule, appRouteObserver; show
accountModule,
animojiModule,
api,
appRouteObserver,
bannersModule,
messagesModule,
storiesModule;
import '../../widgets/attachment/attachment_sheet.dart'; import '../../widgets/attachment/attachment_sheet.dart';
import '../../widgets/update_dialog.dart';
import '../stories/story_composer_screen.dart'; import '../stories/story_composer_screen.dart';
import '../stories/story_owner_info.dart'; import '../stories/story_owner_info.dart';
import '../stories/story_ring.dart'; import '../stories/story_ring.dart';
@@ -228,6 +240,7 @@ class _ChatListScreenState extends State<ChatListScreen>
StreamSubscription<LoginStatus>? _loginSub; StreamSubscription<LoginStatus>? _loginSub;
StreamSubscription<Packet>? _typingSub; StreamSubscription<Packet>? _typingSub;
StreamSubscription<MessageEvent>? _typingMsgSub; StreamSubscription<MessageEvent>? _typingMsgSub;
String? _presentedInformerId;
Widget? _cachedChatsBody; Widget? _cachedChatsBody;
Object? _chatsBodyCacheKey; Object? _chatsBodyCacheKey;
@@ -593,6 +606,7 @@ class _ChatListScreenState extends State<ChatListScreen>
KometSettings.hideAllChatsFolder.addListener(_requestReload); KometSettings.hideAllChatsFolder.addListener(_requestReload);
KometSettings.showHiddenChats.addListener(_requestReload); KometSettings.showHiddenChats.addListener(_requestReload);
ContactsModule.revision.addListener(_requestReload); ContactsModule.revision.addListener(_requestReload);
bannersModule.activeBanner.addListener(_onActiveInformerChanged);
_maybeLoadStories(); _maybeLoadStories();
_typingSub = api.pushStream _typingSub = api.pushStream
.where((p) => p.opcode == Opcode.notifTyping) .where((p) => p.opcode == Opcode.notifTyping)
@@ -719,6 +733,7 @@ class _ChatListScreenState extends State<ChatListScreen>
unawaited(_runReload()); unawaited(_runReload());
} }
}); });
_scheduleInformerPresentation();
} }
void _requestReload() { void _requestReload() {
@@ -1244,6 +1259,7 @@ class _ChatListScreenState extends State<ChatListScreen>
KometSettings.hideAllChatsFolder.removeListener(_requestReload); KometSettings.hideAllChatsFolder.removeListener(_requestReload);
KometSettings.showHiddenChats.removeListener(_requestReload); KometSettings.showHiddenChats.removeListener(_requestReload);
ContactsModule.revision.removeListener(_requestReload); ContactsModule.revision.removeListener(_requestReload);
bannersModule.activeBanner.removeListener(_onActiveInformerChanged);
_loginSub?.cancel(); _loginSub?.cancel();
_stateSub?.cancel(); _stateSub?.cancel();
_typingSub?.cancel(); _typingSub?.cancel();
@@ -1302,6 +1318,7 @@ class _ChatListScreenState extends State<ChatListScreen>
_navPageAnimEnd = index.toDouble(); _navPageAnimEnd = index.toDouble();
setState(() => _currentNavIndex = index); setState(() => _currentNavIndex = index);
_navPageAnimController.forward(from: 0); _navPageAnimController.forward(from: 0);
if (index == 0) _scheduleInformerPresentation();
} }
void _toggleFab() { void _toggleFab() {
@@ -1316,6 +1333,115 @@ class _ChatListScreenState extends State<ChatListScreen>
}); });
} }
void _markInformerPresented(InformerBanner banner) {
if (widget.forwardMode || widget.archiveMode || _currentNavIndex != 0) {
return;
}
final route = ModalRoute.of(context);
if (route != null && !route.isCurrent) return;
if (_presentedInformerId == banner.id) return;
if (bannersModule.activeBanner.value?.id != banner.id) return;
_presentedInformerId = banner.id;
unawaited(_persistInformerPresentation(banner));
}
Future<void> _persistInformerPresentation(InformerBanner banner) async {
try {
await bannersModule.markShown(banner);
} catch (_) {}
}
void _onActiveInformerChanged() {
if (bannersModule.activeBanner.value == null) {
_presentedInformerId = null;
return;
}
_scheduleInformerPresentation();
}
void _scheduleInformerPresentation() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final banner = bannersModule.activeBanner.value;
if (banner != null) _markInformerPresented(banner);
});
}
Future<void> _closeInformer(InformerBanner banner) async {
Haptics.tap();
try {
await bannersModule.close(banner);
} catch (_) {
bannersModule.refresh();
}
}
Future<void> _openInformer(InformerBanner banner) async {
Haptics.tap();
try {
await bannersModule.markClicked(banner);
} catch (_) {
bannersModule.refresh();
}
if (!mounted) return;
final url = banner.url?.trim();
if (url != null && url.isNotEmpty) {
await openExternalUrl(context, url);
return;
}
if (!banner.isUpdate) return;
final result = await UpdateChecker.checkNow();
if (!mounted) return;
switch (result.status) {
case UpdateCheckStatus.updateAvailable:
await showUpdateDialog(context, result.update!);
return;
case UpdateCheckStatus.upToDate:
showCustomNotification(
context,
AppLocalizations.of(context)!.updateUpToDate,
);
return;
case UpdateCheckStatus.failed:
showCustomNotification(
context,
AppLocalizations.of(context)!.updateCheckFailed,
);
return;
}
}
Widget _buildInformerBanner() {
return ValueListenableBuilder<InformerBanner?>(
valueListenable: bannersModule.activeBanner,
builder: (context, banner, _) {
return ClipRect(
child: AnimatedSize(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
alignment: Alignment.topCenter,
child: banner == null
? const SizedBox(width: double.infinity)
: InformerBannerTile(
key: ValueKey(banner.id),
banner: banner,
animojiLoader: animojiModule.fetchById,
onPresented: _markInformerPresented,
onTap: banner.isClickable
? () => unawaited(_openInformer(banner))
: null,
onClose: banner.hidesCloseButton
? null
: () => unawaited(_closeInformer(banner)),
),
),
);
},
);
}
Widget _buildPinnedChatsHeader(BuildContext context) { Widget _buildPinnedChatsHeader(BuildContext context) {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
return ColoredBox( return ColoredBox(
@@ -1562,6 +1688,7 @@ class _ChatListScreenState extends State<ChatListScreen>
), ),
), ),
), ),
if (!widget.forwardMode) _buildInformerBanner(),
], ],
), ),
); );
@@ -1898,6 +2025,7 @@ class _ChatListScreenState extends State<ChatListScreen>
_currentNavIndex = next; _currentNavIndex = next;
_navDragging = false; _navDragging = false;
}); });
if (next == 0) _scheduleInformerPresentation();
}, },
onHorizontalDragCancel: () { onHorizontalDragCancel: () {
if (!_navDragging) return; if (!_navDragging) return;
@@ -215,6 +215,7 @@ class ChatScreen extends StatefulWidget {
final String name; final String name;
final String imageUrl; final String imageUrl;
final String chatType; final String chatType;
final bool? channelSubscribed;
final bool embedded; final bool embedded;
final VoidCallback? onClose; final VoidCallback? onClose;
final ForwardRequest? forwardRequest; final ForwardRequest? forwardRequest;
@@ -230,6 +231,7 @@ class ChatScreen extends StatefulWidget {
required this.name, required this.name,
required this.imageUrl, required this.imageUrl,
required this.chatType, required this.chatType,
this.channelSubscribed,
this.embedded = false, this.embedded = false,
this.onClose, this.onClose,
this.forwardRequest, this.forwardRequest,
@@ -591,6 +593,7 @@ class _ChatScreenState extends State<ChatScreen>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_previewChat = widget.channelSubscribed == false;
_chatController.chatId = widget.chatId; _chatController.chatId = widget.chatId;
_chatController.isMounted = () => mounted; _chatController.isMounted = () => mounted;
unawaited(PushService.clearChatNotification(widget.chatId)); unawaited(PushService.clearChatNotification(widget.chatId));
@@ -785,8 +788,15 @@ class _ChatScreenState extends State<ChatScreen>
final chatRows = await chats.getChat(_myId, widget.chatId); final chatRows = await chats.getChat(_myId, widget.chatId);
if (!mounted) return; if (!mounted) return;
if (chatRows.isNotEmpty) { if (chatRows.isNotEmpty) {
final channelSubscribed =
widget.chatType != 'CHANNEL' ||
await AppDatabase.isChatInList(_myId, widget.chatId);
if (!mounted) return;
setState(() { setState(() {
chat = chatRows.first; chat = chatRows.first;
if (widget.chatType == 'CHANNEL') {
_previewChat = !channelSubscribed;
}
}); });
_bumpMessages(); _bumpMessages();
_seedPresenceFromChat(); _seedPresenceFromChat();
@@ -0,0 +1,244 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../models/animoji.dart';
import '../../models/informer_banner.dart';
import 'lottie_image.dart';
typedef InformerAnimojiLoader = Future<Animoji?> Function(int id);
class InformerBannerTile extends StatefulWidget {
final InformerBanner banner;
final InformerAnimojiLoader? animojiLoader;
final ValueChanged<InformerBanner>? onPresented;
final VoidCallback? onTap;
final VoidCallback? onClose;
const InformerBannerTile({
super.key,
required this.banner,
this.animojiLoader,
this.onPresented,
this.onTap,
this.onClose,
});
@override
State<InformerBannerTile> createState() => _InformerBannerTileState();
}
class _InformerBannerTileState extends State<InformerBannerTile>
with SingleTickerProviderStateMixin {
Future<Animoji?>? _animoji;
late final AnimationController _textController;
late final Animation<double> _textOpacity;
late final Animation<Offset> _textOffset;
@override
void initState() {
super.initState();
_textController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 420),
value: widget.banner.animatesText ? 0 : 1,
);
final curve = CurvedAnimation(
parent: _textController,
curve: Curves.easeOutCubic,
);
_textOpacity = curve;
_textOffset = Tween<Offset>(
begin: const Offset(0.035, 0),
end: Offset.zero,
).animate(curve);
_loadAnimoji();
_present();
if (widget.banner.animatesText) _textController.forward();
}
@override
void didUpdateWidget(InformerBannerTile oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.banner.id == widget.banner.id) return;
_loadAnimoji();
_textController.value = widget.banner.animatesText ? 0 : 1;
_present();
if (widget.banner.animatesText) _textController.forward();
}
@override
void dispose() {
_textController.dispose();
super.dispose();
}
void _loadAnimoji() {
final id = widget.banner.animojiId;
final loader = widget.animojiLoader;
_animoji = id == null || loader == null ? null : loader(id);
}
void _present() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.onPresented?.call(widget.banner);
});
}
@override
Widget build(BuildContext context) {
final banner = widget.banner;
final cs = Theme.of(context).colorScheme;
final background = Color.alphaBlend(
cs.primary.withValues(alpha: 0.12),
cs.surfaceContainerLow,
);
final content = Semantics(
button: widget.onTap != null,
label: [
banner.title,
banner.description,
].where((text) => text.isNotEmpty).join('. '),
child: InkWell(
key: ValueKey('informer-banner-${banner.id}'),
onTap: widget.onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 66),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 9, 8, 9),
child: Row(
children: [
_InformerBannerIcon(
future: _animoji,
tintWithTheme: banner.tintsIconWithTheme,
),
const SizedBox(width: 14),
Expanded(
child: FadeTransition(
opacity: _textOpacity,
child: SlideTransition(
position: _textOffset,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (banner.title.isNotEmpty)
Text(
banner.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.primary,
fontSize: 14.5,
fontWeight: FontWeight.w600,
height: 1.2,
),
),
if (banner.title.isNotEmpty &&
banner.description.isNotEmpty)
const SizedBox(height: 3),
if (banner.description.isNotEmpty)
Text(
banner.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurface,
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.2,
),
),
],
),
),
),
),
if (!banner.hidesCloseButton)
IconButton(
key: ValueKey('informer-banner-close-${banner.id}'),
tooltip: MaterialLocalizations.of(
context,
).closeButtonTooltip,
onPressed: widget.onClose,
visualDensity: VisualDensity.compact,
iconSize: 19,
color: cs.onSurfaceVariant.withValues(alpha: 0.72),
icon: const Icon(Symbols.cancel, fill: 0, weight: 450),
),
],
),
),
),
),
);
return Material(color: background, child: content);
}
}
class _InformerBannerIcon extends StatelessWidget {
final Future<Animoji?>? future;
final bool tintWithTheme;
const _InformerBannerIcon({
required this.future,
required this.tintWithTheme,
});
@override
Widget build(BuildContext context) {
if (future == null) return const _InformerBannerFallbackIcon();
return FutureBuilder<Animoji?>(
future: future,
builder: (context, snapshot) {
final animoji = snapshot.data;
if (animoji == null) return const _InformerBannerFallbackIcon();
Widget icon = LottieImage(
url: animoji.iconUrl,
lottieUrl: animoji.lottieUrl,
size: 44,
memCacheWidth: 96,
shimmer: false,
eager: true,
);
if (tintWithTheme) {
icon = ColorFiltered(
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.primary,
BlendMode.srcIn,
),
child: icon,
);
}
return SizedBox(
key: const ValueKey('informer-banner-animoji'),
width: 44,
height: 44,
child: icon,
);
},
);
}
}
class _InformerBannerFallbackIcon extends StatelessWidget {
const _InformerBannerFallbackIcon();
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Container(
key: const ValueKey('informer-banner-fallback-icon'),
width: 44,
height: 44,
decoration: BoxDecoration(color: cs.primary, shape: BoxShape.circle),
alignment: Alignment.center,
child: Icon(
Symbols.chat_bubble,
color: cs.onPrimary,
size: 23,
fill: 0,
weight: 500,
),
);
}
}
+10 -6
View File
@@ -106,11 +106,7 @@ Future<void> _openResolvedChat(
final profile = await AppDatabase.loadActiveProfile(); final profile = await AppDatabase.loadActiveProfile();
final myId = profile?.id ?? 0; final myId = profile?.id ?? 0;
final participants = chat['participants']; var isMember = myId != 0 && await AppDatabase.isChatInList(myId, id);
final isMember =
myId != 0 &&
participants is Map &&
participants.containsKey(myId.toString());
await chats.cacheServerChat(chat, myId, inList: isMember); await chats.cacheServerChat(chat, myId, inList: isMember);
if (!context.mounted) return; if (!context.mounted) return;
@@ -130,12 +126,20 @@ Future<void> _openResolvedChat(
if (context.mounted) showCustomNotification(context, error); if (context.mounted) showCustomNotification(context, error);
return; return;
} }
isMember = true;
await chats.cacheServerChat(chat, myId, inList: true);
if (!context.mounted) return; if (!context.mounted) return;
} }
pushSwipeable( pushSwipeable(
context, context,
(_) => ChatScreen(chatId: id, name: title, imageUrl: icon, chatType: type), (_) => ChatScreen(
chatId: id,
name: title,
imageUrl: icon,
chatType: type,
channelSubscribed: type == 'CHANNEL' ? isMember : null,
),
); );
} }
+27
View File
@@ -0,0 +1,27 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/backend/api.dart';
import 'package:komet/backend/modules/banners.dart';
import 'package:komet/models/informer_banner.dart';
void main() {
test('a pinned banner records one presentation', () async {
final api = Api();
addTearDown(api.dispose);
final module = BannersModule(api);
const banner = InformerBanner(
id: 'synthetic-banner',
title: 'Synthetic title',
repeat: 3,
);
await module.markShown(banner);
await module.markShown(banner);
expect(module.stateOf(banner.id).showCounter, 1);
await module.close(banner);
await module.markShown(banner);
expect(module.stateOf(banner.id).showCounter, 2);
});
}
+11
View File
@@ -0,0 +1,11 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/core/storage/app_database.dart';
void main() {
test('chat list state distinguishes previews from memberships', () {
expect(AppDatabase.chatRowIsInList({'in_list': 0}), isFalse);
expect(AppDatabase.chatRowIsInList({'in_list': 1}), isTrue);
expect(AppDatabase.chatRowIsInList({'in_list': 2}), isTrue);
expect(AppDatabase.chatRowIsInList({}), isTrue);
});
}
+122
View File
@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/frontend/widgets/informer_banner_tile.dart';
import 'package:komet/models/animoji.dart';
import 'package:komet/models/informer_banner.dart';
const _banner = InformerBanner(
id: 'synthetic-banner',
title: 'Synthetic title',
description: 'Synthetic description',
settings: BannerSettings.textAnimation,
type: BannerType.link,
url: 'https://example.invalid/synthetic',
);
Widget _app(Widget child) => MaterialApp(home: Scaffold(body: child));
void main() {
testWidgets('renders the banner content and reports presentation once', (
tester,
) async {
var presentations = 0;
await tester.pumpWidget(
_app(
InformerBannerTile(
banner: _banner,
onPresented: (_) => presentations++,
),
),
);
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('Synthetic title'), findsOneWidget);
expect(find.text('Synthetic description'), findsOneWidget);
expect(
find.byKey(const ValueKey('informer-banner-fallback-icon')),
findsOneWidget,
);
expect(presentations, 1);
await tester.pumpWidget(
_app(
InformerBannerTile(
banner: _banner,
onPresented: (_) => presentations++,
),
),
);
await tester.pump();
expect(presentations, 1);
});
testWidgets('handles body and close actions independently', (tester) async {
var taps = 0;
var closes = 0;
await tester.pumpWidget(
_app(
InformerBannerTile(
banner: _banner,
onTap: () => taps++,
onClose: () => closes++,
),
),
);
await tester.pump();
await tester.tap(
find.byKey(const ValueKey('informer-banner-synthetic-banner')),
);
await tester.pump();
expect(taps, 1);
expect(closes, 0);
await tester.tap(
find.byKey(const ValueKey('informer-banner-close-synthetic-banner')),
);
await tester.pump();
expect(taps, 1);
expect(closes, 1);
});
testWidgets('honors close visibility and resolves the configured animoji', (
tester,
) async {
int? requestedId;
const banner = InformerBanner(
id: 'synthetic-themed-banner',
title: 'Synthetic themed title',
settings: BannerSettings.hideCloseButton | BannerSettings.iconThemeColor,
animojiId: 42,
);
await tester.pumpWidget(
_app(
InformerBannerTile(
banner: banner,
animojiLoader: (id) async {
requestedId = id;
return const Animoji(id: 42, emoji: '🧪');
},
),
),
);
await tester.pump();
expect(requestedId, 42);
expect(
find.byKey(const ValueKey('informer-banner-animoji')),
findsOneWidget,
);
expect(
find.byKey(
const ValueKey('informer-banner-close-synthetic-themed-banner'),
),
findsNothing,
);
expect(find.byType(ColorFiltered), findsOneWidget);
});
}