банеры
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user