From 35ab9a26c0125b96dc56462479849e71d07cdb8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Sat, 15 Jun 2024 15:56:50 +0200 Subject: [PATCH] Cache messages, use cache if exists, load in background --- flutter/Makefile | 4 +- flutter/lib/components/layout/app_bar.dart | 43 +++-- flutter/lib/main.dart | 28 ++- flutter/lib/models/channel.dart | 38 +++- flutter/lib/models/channel.g.dart | 65 +++++++ flutter/lib/models/message.dart | 47 ++++- flutter/lib/models/message.g.dart | 77 ++++++++ flutter/lib/nav_layout.dart | 10 +- flutter/lib/pages/account/account.dart | 27 ++- .../lib/pages/channel_list/channel_list.dart | 35 +++- .../lib/pages/debug/debug_persistence.dart | 38 ++++ .../lib/pages/message_list/message_list.dart | 174 +++++++++++++++++- flutter/lib/pages/send/send.dart | 2 +- flutter/lib/pages/settings/root.dart | 2 +- flutter/lib/state/app_auth.dart | 7 +- flutter/lib/utils/navi.dart | 1 + 16 files changed, 556 insertions(+), 42 deletions(-) create mode 100644 flutter/lib/models/channel.g.dart create mode 100644 flutter/lib/models/message.g.dart diff --git a/flutter/Makefile b/flutter/Makefile index e2abffc..d5fd80c 100644 --- a/flutter/Makefile +++ b/flutter/Makefile @@ -1,7 +1,7 @@ run: - dart run build_runner build + flutter pub run build_runner build flutter run test: @@ -11,7 +11,7 @@ fix: dart fix --apply gen: - dart run build_runner build + flutter pub run build_runner build autoreload: @# run `make run` in another terminal (or another variant of flutter run) diff --git a/flutter/lib/components/layout/app_bar.dart b/flutter/lib/components/layout/app_bar.dart index 3e03ac3..165e0fc 100644 --- a/flutter/lib/components/layout/app_bar.dart +++ b/flutter/lib/components/layout/app_bar.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:provider/provider.dart'; import 'package:simplecloudnotifier/components/layout/app_bar_progress_indicator.dart'; @@ -28,6 +29,16 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget { Widget build(BuildContext context) { var actions = []; + if (showDebug) { + actions.add(IconButton( + icon: const Icon(FontAwesomeIcons.solidSpiderBlackWidow), + tooltip: 'Debug', + onPressed: () { + Navi.push(context, () => DebugMainPage()); + }, + )); + } + if (showThemeSwitch) { actions.add(Consumer( builder: (context, appTheme, child) => IconButton( @@ -37,19 +48,16 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget { ), )); } else { - actions.add(SizedBox.square(dimension: 40)); - } - - if (showDebug) { - actions.add(IconButton( - icon: const Icon(FontAwesomeIcons.solidSpiderBlackWidow), - tooltip: 'Debug', - onPressed: () { - Navi.push(context, () => DebugMainPage()); - }, + actions.add(Visibility( + visible: false, + maintainSize: true, + maintainAnimation: true, + maintainState: true, + child: IconButton( + icon: const Icon(FontAwesomeIcons.square), + onPressed: () {/*TODO*/}, + ), )); - } else { - actions.add(SizedBox.square(dimension: 40)); } if (showSearch) { @@ -65,7 +73,16 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget { onPressed: onShare ?? () {}, )); } else { - actions.add(SizedBox.square(dimension: 40)); + actions.add(Visibility( + visible: false, + maintainSize: true, + maintainAnimation: true, + maintainState: true, + child: IconButton( + icon: const Icon(FontAwesomeIcons.square), + onPressed: () {/*TODO*/}, + ), + )); } return AppBar( diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index f7a47c3..42c08da 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -5,7 +5,9 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:simplecloudnotifier/api/api_client.dart'; +import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/client.dart'; +import 'package:simplecloudnotifier/models/message.dart'; import 'package:simplecloudnotifier/nav_layout.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/app_theme.dart'; @@ -36,6 +38,8 @@ void main() async { Hive.registerAdapter(SCNRequestAdapter()); Hive.registerAdapter(SCNLogAdapter()); Hive.registerAdapter(SCNLogLevelAdapter()); + Hive.registerAdapter(MessageAdapter()); + Hive.registerAdapter(ChannelAdapter()); print('[INIT] Load Hive...'); @@ -57,6 +61,26 @@ void main() async { ApplicationLog.error('Failed to open Hive-Box: scn-logs: ' + exc.toString(), trace: trace); } + print('[INIT] Load Hive...'); + + try { + await Hive.openBox('scn-message-cache'); + } catch (exc, trace) { + Hive.deleteBoxFromDisk('scn-message-cache'); + await Hive.openBox('scn-message-cache'); + ApplicationLog.error('Failed to open Hive-Box: scn-message-cache' + exc.toString(), trace: trace); + } + + print('[INIT] Load Hive...'); + + try { + await Hive.openBox('scn-channel-cache'); + } catch (exc, trace) { + Hive.deleteBoxFromDisk('scn-channel-cache'); + await Hive.openBox('scn-channel-cache'); + ApplicationLog.error('Failed to open Hive-Box: scn-channel-cache' + exc.toString(), trace: trace); + } + print('[INIT] Load AppAuth...'); final appAuth = AppAuth(); // ensure UserAccount is loaded @@ -135,7 +159,7 @@ void setFirebaseToken(String fcmToken) async { Client? client; try { - client = await acc.loadClient(force: true); + client = await acc.loadClient(forceIfOlder: Duration(seconds: 60)); } catch (exc, trace) { ApplicationLog.error('Failed to get client: ' + exc.toString(), trace: trace); return; @@ -172,7 +196,7 @@ class SCNApp extends StatelessWidget { child: Consumer( builder: (context, appTheme, child) => MaterialApp( title: 'SimpleCloudNotifier', - navigatorObservers: [Navi.routeObserver], + navigatorObservers: [Navi.routeObserver, Navi.modalRouteObserver], theme: ThemeData( //TODO color settings colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue, brightness: appTheme.darkMode ? Brightness.dark : Brightness.light), diff --git a/flutter/lib/models/channel.dart b/flutter/lib/models/channel.dart index 6ea330e..9588fa4 100644 --- a/flutter/lib/models/channel.dart +++ b/flutter/lib/models/channel.dart @@ -1,17 +1,32 @@ +import 'package:hive_flutter/hive_flutter.dart'; import 'package:simplecloudnotifier/models/subscription.dart'; +import 'package:simplecloudnotifier/state/interfaces.dart'; -class Channel { +part 'channel.g.dart'; + +@HiveType(typeId: 104) +class Channel extends HiveObject implements FieldDebuggable { + @HiveField(0) final String channelID; + + @HiveField(10) final String ownerUserID; + @HiveField(11) final String internalName; + @HiveField(12) final String displayName; + @HiveField(13) final String? descriptionName; + @HiveField(14) final String? subscribeKey; + @HiveField(15) final String timestampCreated; + @HiveField(16) final String? timestampLastSent; + @HiveField(17) final int messagesSent; - const Channel({ + Channel({ required this.channelID, required this.ownerUserID, required this.internalName, @@ -36,6 +51,25 @@ class Channel { messagesSent: json['messages_sent'] as int, ); } + + @override + String toString() { + return 'Channel[${this.channelID}]'; + } + + List<(String, String)> debugFieldList() { + return [ + ('channelID', this.channelID), + ('ownerUserID', this.ownerUserID), + ('internalName', this.internalName), + ('displayName', this.displayName), + ('descriptionName', this.descriptionName ?? ''), + ('subscribeKey', this.subscribeKey ?? ''), + ('timestampCreated', this.timestampCreated), + ('timestampLastSent', this.timestampLastSent ?? ''), + ('messagesSent', '${this.messagesSent}'), + ]; + } } class ChannelWithSubscription { diff --git a/flutter/lib/models/channel.g.dart b/flutter/lib/models/channel.g.dart new file mode 100644 index 0000000..fc2999d --- /dev/null +++ b/flutter/lib/models/channel.g.dart @@ -0,0 +1,65 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'channel.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class ChannelAdapter extends TypeAdapter { + @override + final int typeId = 104; + + @override + Channel read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return Channel( + channelID: fields[0] as String, + ownerUserID: fields[10] as String, + internalName: fields[11] as String, + displayName: fields[12] as String, + descriptionName: fields[13] as String?, + subscribeKey: fields[14] as String?, + timestampCreated: fields[15] as String, + timestampLastSent: fields[16] as String?, + messagesSent: fields[17] as int, + ); + } + + @override + void write(BinaryWriter writer, Channel obj) { + writer + ..writeByte(9) + ..writeByte(0) + ..write(obj.channelID) + ..writeByte(10) + ..write(obj.ownerUserID) + ..writeByte(11) + ..write(obj.internalName) + ..writeByte(12) + ..write(obj.displayName) + ..writeByte(13) + ..write(obj.descriptionName) + ..writeByte(14) + ..write(obj.subscribeKey) + ..writeByte(15) + ..write(obj.timestampCreated) + ..writeByte(16) + ..write(obj.timestampLastSent) + ..writeByte(17) + ..write(obj.messagesSent); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ChannelAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/flutter/lib/models/message.dart b/flutter/lib/models/message.dart index 563ede4..4d4b18c 100644 --- a/flutter/lib/models/message.dart +++ b/flutter/lib/models/message.dart @@ -1,19 +1,39 @@ -class Message { +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:simplecloudnotifier/state/interfaces.dart'; + +part 'message.g.dart'; + +@HiveType(typeId: 105) +class Message extends HiveObject implements FieldDebuggable { + @HiveField(0) final String messageID; + + @HiveField(10) final String senderUserID; + @HiveField(11) final String channelInternalName; + @HiveField(12) final String channelID; + @HiveField(13) final String? senderName; + @HiveField(14) final String senderIP; + @HiveField(15) final String timestamp; + @HiveField(16) final String title; + @HiveField(17) final String? content; + @HiveField(18) final int priority; + @HiveField(19) final String? userMessageID; + @HiveField(20) final String usedKeyID; + @HiveField(21) final bool trimmed; - const Message({ + Message({ required this.messageID, required this.senderUserID, required this.channelInternalName, @@ -54,4 +74,27 @@ class Message { return (npt, messages); } + + @override + String toString() { + return 'Message[${this.messageID}]'; + } + + List<(String, String)> debugFieldList() { + return [ + ('messageID', this.messageID), + ('senderUserID', this.senderUserID), + ('channelInternalName', this.channelInternalName), + ('channelID', this.channelID), + ('senderName', this.senderName ?? ''), + ('senderIP', this.senderIP), + ('timestamp', this.timestamp), + ('title', this.title), + ('content', this.content ?? ''), + ('priority', '${this.priority}'), + ('userMessageID', this.userMessageID ?? ''), + ('usedKeyID', this.usedKeyID), + ('trimmed', '${this.trimmed}'), + ]; + } } diff --git a/flutter/lib/models/message.g.dart b/flutter/lib/models/message.g.dart new file mode 100644 index 0000000..6bfc06a --- /dev/null +++ b/flutter/lib/models/message.g.dart @@ -0,0 +1,77 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'message.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class MessageAdapter extends TypeAdapter { + @override + final int typeId = 105; + + @override + Message read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return Message( + messageID: fields[0] as String, + senderUserID: fields[10] as String, + channelInternalName: fields[11] as String, + channelID: fields[12] as String, + senderName: fields[13] as String?, + senderIP: fields[14] as String, + timestamp: fields[15] as String, + title: fields[16] as String, + content: fields[17] as String?, + priority: fields[18] as int, + userMessageID: fields[19] as String?, + usedKeyID: fields[20] as String, + trimmed: fields[21] as bool, + ); + } + + @override + void write(BinaryWriter writer, Message obj) { + writer + ..writeByte(13) + ..writeByte(0) + ..write(obj.messageID) + ..writeByte(10) + ..write(obj.senderUserID) + ..writeByte(11) + ..write(obj.channelInternalName) + ..writeByte(12) + ..write(obj.channelID) + ..writeByte(13) + ..write(obj.senderName) + ..writeByte(14) + ..write(obj.senderIP) + ..writeByte(15) + ..write(obj.timestamp) + ..writeByte(16) + ..write(obj.title) + ..writeByte(17) + ..write(obj.content) + ..writeByte(18) + ..write(obj.priority) + ..writeByte(19) + ..write(obj.userMessageID) + ..writeByte(20) + ..write(obj.usedKeyID) + ..writeByte(21) + ..write(obj.trimmed); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MessageAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/flutter/lib/nav_layout.dart b/flutter/lib/nav_layout.dart index 88e1af2..b5265e7 100644 --- a/flutter/lib/nav_layout.dart +++ b/flutter/lib/nav_layout.dart @@ -66,11 +66,11 @@ class _SCNNavLayoutState extends State { ), body: IndexedStack( children: [ - ExcludeFocus(excluding: _selectedIndex != 0, child: MessageListPage()), - ExcludeFocus(excluding: _selectedIndex != 1, child: ChannelRootPage()), - ExcludeFocus(excluding: _selectedIndex != 2, child: AccountRootPage()), - ExcludeFocus(excluding: _selectedIndex != 3, child: SettingsRootPage()), - ExcludeFocus(excluding: _selectedIndex != 4, child: SendRootPage()), + ExcludeFocus(excluding: _selectedIndex != 0, child: MessageListPage(isVisiblePage: _selectedIndex == 0)), + ExcludeFocus(excluding: _selectedIndex != 1, child: ChannelRootPage(isVisiblePage: _selectedIndex == 1)), + ExcludeFocus(excluding: _selectedIndex != 2, child: AccountRootPage(isVisiblePage: _selectedIndex == 2)), + ExcludeFocus(excluding: _selectedIndex != 3, child: SettingsRootPage(isVisiblePage: _selectedIndex == 3)), + ExcludeFocus(excluding: _selectedIndex != 4, child: SendRootPage(isVisiblePage: _selectedIndex == 4)), ], index: _selectedIndex, ), diff --git a/flutter/lib/pages/account/account.dart b/flutter/lib/pages/account/account.dart index 9864ae9..4fd6749 100644 --- a/flutter/lib/pages/account/account.dart +++ b/flutter/lib/pages/account/account.dart @@ -16,7 +16,9 @@ import 'package:simplecloudnotifier/utils/ui.dart'; import 'package:uuid/uuid.dart'; class AccountRootPage extends StatefulWidget { - const AccountRootPage({super.key}); + const AccountRootPage({super.key, required this.isVisiblePage}); + + final bool isVisiblePage; @override State createState() => _AccountRootPageState(); @@ -33,13 +35,34 @@ class _AccountRootPageState extends State { bool loading = false; + bool _isInitialized = false; + @override void initState() { super.initState(); userAcc = Provider.of(context, listen: false); userAcc.addListener(_onAuthStateChanged); + + if (widget.isVisiblePage && !_isInitialized) realInitState(); + } + + @override + void didUpdateWidget(AccountRootPage oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.isVisiblePage != widget.isVisiblePage && widget.isVisiblePage) { + if (!_isInitialized) { + realInitState(); + } else { + //TODO background refresh + } + } + } + + void realInitState() { _onAuthStateChanged(); + _isInitialized = true; } @override @@ -92,6 +115,8 @@ class _AccountRootPageState extends State { Widget build(BuildContext context) { return Consumer( builder: (context, acc, child) { + if (!_isInitialized) return SizedBox(); + if (!userAcc.isAuth()) { return _buildNoAuth(context); } else { diff --git a/flutter/lib/pages/channel_list/channel_list.dart b/flutter/lib/pages/channel_list/channel_list.dart index 3c70919..1ab5c47 100644 --- a/flutter/lib/pages/channel_list/channel_list.dart +++ b/flutter/lib/pages/channel_list/channel_list.dart @@ -8,21 +8,26 @@ import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/pages/channel_list/channel_list_item.dart'; class ChannelRootPage extends StatefulWidget { - const ChannelRootPage({super.key}); + const ChannelRootPage({super.key, required this.isVisiblePage}); + + final bool isVisiblePage; @override State createState() => _ChannelRootPageState(); } class _ChannelRootPageState extends State { - final PagingController _pagingController = PagingController(firstPageKey: 0); + final PagingController _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: 0); + + bool _isInitialized = false; @override void initState() { - _pagingController.addPageRequestListener((pageKey) { - _fetchPage(pageKey); - }); super.initState(); + + _pagingController.addPageRequestListener(_fetchPage); + + if (widget.isVisiblePage && !_isInitialized) realInitState(); } @override @@ -31,9 +36,29 @@ class _ChannelRootPageState extends State { super.dispose(); } + @override + void didUpdateWidget(ChannelRootPage oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.isVisiblePage != widget.isVisiblePage && widget.isVisiblePage) { + if (!_isInitialized) { + realInitState(); + } else { + //TODO background refresh + } + } + } + + void realInitState() { + _pagingController.refresh(); + _isInitialized = true; + } + Future _fetchPage(int pageKey) async { final acc = Provider.of(context, listen: false); + ApplicationLog.debug('Start ChannelList::_pagingController::_fetchPage [ ${pageKey} ]'); + if (!acc.isAuth()) { _pagingController.error = 'Not logged in'; return; diff --git a/flutter/lib/pages/debug/debug_persistence.dart b/flutter/lib/pages/debug/debug_persistence.dart index 3808ee2..15b8cfe 100644 --- a/flutter/lib/pages/debug/debug_persistence.dart +++ b/flutter/lib/pages/debug/debug_persistence.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:simplecloudnotifier/models/channel.dart'; +import 'package:simplecloudnotifier/models/message.dart'; import 'package:simplecloudnotifier/pages/debug/debug_persistence_hive.dart'; import 'package:simplecloudnotifier/pages/debug/debug_persistence_sharedprefs.dart'; import 'package:simplecloudnotifier/state/application_log.dart'; @@ -83,6 +85,42 @@ class _DebugPersistencePageState extends State { ), ), ), + Card.outlined( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: GestureDetector( + onTap: () { + Navi.push(context, () => DebugHiveBoxPage(boxName: 'scn-message-cache', box: Hive.box('scn-message-cache'))); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SizedBox(width: 30, child: Text('')), + Expanded(child: Text('Hive [scn-message-cache]', style: TextStyle(fontWeight: FontWeight.bold), textAlign: TextAlign.center)), + SizedBox(width: 30, child: Text('${Hive.box('scn-message-cache').length.toString()}', textAlign: TextAlign.end)), + ], + ), + ), + ), + ), + Card.outlined( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: GestureDetector( + onTap: () { + Navi.push(context, () => DebugHiveBoxPage(boxName: 'scn-channel-cache', box: Hive.box('scn-channel-cache'))); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SizedBox(width: 30, child: Text('')), + Expanded(child: Text('Hive [scn-channel-cache]', style: TextStyle(fontWeight: FontWeight.bold), textAlign: TextAlign.center)), + SizedBox(width: 30, child: Text('${Hive.box('scn-channel-cache').length.toString()}', textAlign: TextAlign.end)), + ], + ), + ), + ), + ), ], ), ); diff --git a/flutter/lib/pages/message_list/message_list.dart b/flutter/lib/pages/message_list/message_list.dart index d5b0527..e628092 100644 --- a/flutter/lib/pages/message_list/message_list.dart +++ b/flutter/lib/pages/message_list/message_list.dart @@ -1,17 +1,21 @@ import 'package:flutter/material.dart'; +import 'package:hive_flutter/hive_flutter.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:provider/provider.dart'; import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/message.dart'; import 'package:simplecloudnotifier/pages/message_view/message_view.dart'; +import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/pages/message_list/message_list_item.dart'; import 'package:simplecloudnotifier/utils/navi.dart'; class MessageListPage extends StatefulWidget { - const MessageListPage({super.key}); + const MessageListPage({super.key, required this.isVisiblePage}); + + final bool isVisiblePage; //TODO reload on switch to tab //TODO reload on app to foreground @@ -20,31 +24,90 @@ class MessageListPage extends StatefulWidget { State createState() => _MessageListPageState(); } -class _MessageListPageState extends State { +class _MessageListPageState extends State with RouteAware { static const _pageSize = 128; - final PagingController _pagingController = PagingController(firstPageKey: '@start'); + PagingController _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: '@start'); Map? _channels = null; + bool _isInitialized = false; + @override void initState() { - //TODO init with state from cache - also allow tho show cache on error - _pagingController.addPageRequestListener((pageKey) { - _fetchPage(pageKey); - }); super.initState(); + + _pagingController.addPageRequestListener(_fetchPage); + + if (widget.isVisiblePage && !_isInitialized) realInitState(); + } + + @override + void didUpdateWidget(MessageListPage oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.isVisiblePage != widget.isVisiblePage && widget.isVisiblePage) { + if (!_isInitialized) { + realInitState(); + } else { + _backgroundRefresh(false); + } + } + } + + void realInitState() { + final chnCache = Hive.box('scn-channel-cache'); + final msgCache = Hive.box('scn-message-cache'); + + if (chnCache.isNotEmpty && msgCache.isNotEmpty) { + // ==== Use cache values - and refresh in background + + _channels = {for (var v in chnCache.values) v.channelID: v}; + + final cacheMessages = msgCache.values.toList(); + cacheMessages.sort((a, b) => -1 * a.timestamp.compareTo(b.timestamp)); + + _pagingController.value = PagingState(nextPageKey: null, itemList: cacheMessages, error: null); + + _backgroundRefresh(true); + } else { + // ==== Full refresh - no cache available + _pagingController.refresh(); + } + + _isInitialized = true; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + Navi.modalRouteObserver.subscribe(this, ModalRoute.of(context)!); } @override void dispose() { + Navi.modalRouteObserver.unsubscribe(this); _pagingController.dispose(); super.dispose(); } + @override + void didPush() { + // Route was pushed onto navigator and is now the topmost route. + ApplicationLog.debug('[MessageList::RouteObserver] --> didPush'); + } + + @override + void didPopNext() { + // Covering route was popped off the navigator. + ApplicationLog.debug('[MessageList::RouteObserver] --> didPopNext'); + } + Future _fetchPage(String thisPageToken) async { final acc = Provider.of(context, listen: false); + ApplicationLog.debug('Start MessageList::_pagingController::_fetchPage [ ${thisPageToken} ]'); + if (!acc.isAuth()) { _pagingController.error = 'Not logged in'; return; @@ -54,10 +117,16 @@ class _MessageListPageState extends State { if (_channels == null) { final channels = await APIClient.getChannelList(acc, ChannelSelector.allAny); _channels = {for (var v in channels) v.channel.channelID: v.channel}; + + _setChannelCache(channels); // no await } final (npt, newItems) = await APIClient.getMessageList(acc, thisPageToken, pageSize: _pageSize); + _addToMessageCache(newItems); // no await + + ApplicationLog.debug('Finished MessageList::_pagingController::_fetchPage [ ${newItems.length} items and npt: ${thisPageToken} --> ${npt} ]'); + if (npt == '@end') { _pagingController.appendLastPage(newItems); } else { @@ -69,6 +138,71 @@ class _MessageListPageState extends State { } } + Future _backgroundRefresh(bool fullReplaceState) async { + final acc = Provider.of(context, listen: false); + + ApplicationLog.debug('Start background refresh of message list (fullReplaceState: $fullReplaceState)'); + + try { + await Future.delayed(const Duration(seconds: 0), () {}); // this is annoyingly important - otherwise we call setLoadingIndeterminate directly in initStat() and get an exception.... + + AppBarState().setLoadingIndeterminate(true); + + if (_channels == null || fullReplaceState) { + final channels = await APIClient.getChannelList(acc, ChannelSelector.allAny); + setState(() { + _channels = {for (var v in channels) v.channel.channelID: v.channel}; + }); + _setChannelCache(channels); // no await + } + + final (npt, newItems) = await APIClient.getMessageList(acc, '@start', pageSize: _pageSize); + + _addToMessageCache(newItems); // no await + + if (fullReplaceState) { + // fully replace/reset state + ApplicationLog.debug('Background-refresh finished (fullReplaceState) - replace state with ${newItems.length} items and npt: [ $npt ]'); + setState(() { + if (npt == '@end') + _pagingController.value = PagingState(nextPageKey: null, itemList: newItems, error: null); + else + _pagingController.value = PagingState(nextPageKey: npt, itemList: newItems, error: null); + }); + } else { + final itemsToBeAdded = newItems.where((p1) => !(_pagingController.itemList ?? []).any((p2) => p1.messageID == p2.messageID)).toList(); + if (itemsToBeAdded.isEmpty) { + // nothing to do - no new items... + // .... + ApplicationLog.debug('Background-refresh returned no new items - nothing to do.'); + } else if (itemsToBeAdded.length == newItems.length) { + // all items are new ?!?, the current state is completely fucked - full replace + ApplicationLog.debug('Background-refresh found only new items ?!? - fully replace state with ${newItems.length} items'); + setState(() { + if (npt == '@end') + _pagingController.value = PagingState(nextPageKey: null, itemList: newItems, error: null); + else + _pagingController.value = PagingState(nextPageKey: npt, itemList: newItems, error: null); + _pagingController.itemList = null; + }); + } else { + // add new items to the front + ApplicationLog.debug('Background-refresh found ${newItems.length} new items - add to front'); + setState(() { + _pagingController.itemList = itemsToBeAdded + (_pagingController.itemList ?? []); + }); + } + } + } catch (exc, trace) { + setState(() { + _pagingController.error = exc.toString(); + }); + ApplicationLog.error('Failed to list messages: ' + exc.toString(), trace: trace); + } finally { + AppBarState().setLoadingIndeterminate(false); + } + } + @override Widget build(BuildContext context) { return Padding( @@ -92,4 +226,30 @@ class _MessageListPageState extends State { ), ); } + + Future _setChannelCache(List channels) async { + final cache = Hive.box('scn-channel-cache'); + + if (cache.length != channels.length) await cache.clear(); + + for (var chn in channels) await cache.put(chn.channel.channelID, chn.channel); + } + + Future _addToMessageCache(List newItems) async { + final cache = Hive.box('scn-message-cache'); + + for (var msg in newItems) await cache.put(msg.messageID, msg); + + // delete all but the newest 128 messages + + if (cache.length < _pageSize) return; + + final allValues = cache.values.toList(); + + allValues.sort((a, b) => -1 * a.timestamp.compareTo(b.timestamp)); + + for (var val in allValues.sublist(_pageSize)) { + await cache.delete(val.messageID); + } + } } diff --git a/flutter/lib/pages/send/send.dart b/flutter/lib/pages/send/send.dart index b333843..b3f47a5 100644 --- a/flutter/lib/pages/send/send.dart +++ b/flutter/lib/pages/send/send.dart @@ -6,7 +6,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:simplecloudnotifier/state/app_auth.dart'; class SendRootPage extends StatefulWidget { - const SendRootPage({super.key}); + const SendRootPage({super.key, required bool isVisiblePage}); @override State createState() => _SendRootPageState(); diff --git a/flutter/lib/pages/settings/root.dart b/flutter/lib/pages/settings/root.dart index 67e050c..3fed0c9 100644 --- a/flutter/lib/pages/settings/root.dart +++ b/flutter/lib/pages/settings/root.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; class SettingsRootPage extends StatefulWidget { - const SettingsRootPage({super.key}); + const SettingsRootPage({super.key, required bool isVisiblePage}); @override State createState() => _SettingsRootPageState(); diff --git a/flutter/lib/state/app_auth.dart b/flutter/lib/state/app_auth.dart index c9d2e8b..109520c 100644 --- a/flutter/lib/state/app_auth.dart +++ b/flutter/lib/state/app_auth.dart @@ -14,6 +14,7 @@ class AppAuth extends ChangeNotifier implements TokenSource { User? _user; Client? _client; + DateTime? _clientQueryTime; String? get userID => _userID; String? get tokenAdmin => _tokenAdmin; @@ -124,7 +125,11 @@ class AppAuth extends ChangeNotifier implements TokenSource { return user; } - Future loadClient({bool force = false}) async { + Future loadClient({bool force = false, Duration? forceIfOlder = null}) async { + if (forceIfOlder != null && _clientQueryTime != null && _clientQueryTime!.difference(DateTime.now()) > forceIfOlder) { + force = true; + } + if (!force && _client != null && _client!.clientID == _clientID) { return _client!; } diff --git a/flutter/lib/utils/navi.dart b/flutter/lib/utils/navi.dart index 51bb5a1..61d9d93 100644 --- a/flutter/lib/utils/navi.dart +++ b/flutter/lib/utils/navi.dart @@ -4,6 +4,7 @@ import 'package:simplecloudnotifier/state/app_bar_state.dart'; class Navi { static final SCNRouteObserver routeObserver = SCNRouteObserver(); + static final RouteObserver> modalRouteObserver = RouteObserver>(); static void push(BuildContext context, T Function() builder) { Provider.of(context, listen: false).setLoadingIndeterminate(false);