From eea219a20580ddfbd40960323e09dfdeb9d48409 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Sat, 15 Jun 2024 16:33:30 +0200 Subject: [PATCH] More background refreshing --- flutter/lib/pages/account/account.dart | 216 +++++++++--------- .../lib/pages/channel_list/channel_list.dart | 41 +++- .../lib/pages/debug/debug_persistence.dart | 10 +- .../lib/pages/message_list/message_list.dart | 16 +- flutter/lib/state/app_auth.dart | 3 - flutter/lib/state/application_log.dart | 2 + flutter/lib/state/request_log.dart | 2 + flutter/lib/types/immediate_future.dart | 18 ++ 8 files changed, 180 insertions(+), 128 deletions(-) create mode 100644 flutter/lib/types/immediate_future.dart diff --git a/flutter/lib/pages/account/account.dart b/flutter/lib/pages/account/account.dart index 4fd6749..6074f6c 100644 --- a/flutter/lib/pages/account/account.dart +++ b/flutter/lib/pages/account/account.dart @@ -7,9 +7,11 @@ import 'package:provider/provider.dart'; import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/models/user.dart'; import 'package:simplecloudnotifier/pages/account/login.dart'; +import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/globals.dart'; import 'package:simplecloudnotifier/state/app_auth.dart'; +import 'package:simplecloudnotifier/types/immediate_future.dart'; import 'package:simplecloudnotifier/utils/navi.dart'; import 'package:simplecloudnotifier/utils/toaster.dart'; import 'package:simplecloudnotifier/utils/ui.dart'; @@ -25,11 +27,12 @@ class AccountRootPage extends StatefulWidget { } class _AccountRootPageState extends State { - late Future? futureSubscriptionCount; - late Future? futureClientCount; - late Future? futureKeyCount; - late Future? futureChannelAllCount; - late Future? futureChannelSubscribedCount; + late ImmediateFuture? futureSubscriptionCount; + late ImmediateFuture? futureClientCount; + late ImmediateFuture? futureKeyCount; + late ImmediateFuture? futureChannelAllCount; + late ImmediateFuture? futureChannelSubscribedCount; + late ImmediateFuture? futureUser; late AppAuth userAcc; @@ -44,7 +47,7 @@ class _AccountRootPageState extends State { userAcc = Provider.of(context, listen: false); userAcc.addListener(_onAuthStateChanged); - if (widget.isVisiblePage && !_isInitialized) realInitState(); + if (widget.isVisiblePage && !_isInitialized) _realInitState(); } @override @@ -53,25 +56,32 @@ class _AccountRootPageState extends State { if (oldWidget.isVisiblePage != widget.isVisiblePage && widget.isVisiblePage) { if (!_isInitialized) { - realInitState(); + _realInitState(); } else { - //TODO background refresh + _backgroundRefresh(); } } } - void realInitState() { + void _realInitState() { + ApplicationLog.debug('AccountRootPage::_realInitState'); _onAuthStateChanged(); _isInitialized = true; } @override void dispose() { + ApplicationLog.debug('AccountRootPage::dispose'); userAcc.removeListener(_onAuthStateChanged); super.dispose(); } void _onAuthStateChanged() { + ApplicationLog.debug('AccountRootPage::_onAuthStateChanged'); + _createFutures(); + } + + void _createFutures() { futureSubscriptionCount = null; futureClientCount = null; futureKeyCount = null; @@ -79,35 +89,70 @@ class _AccountRootPageState extends State { futureChannelSubscribedCount = null; if (userAcc.isAuth()) { - futureChannelAllCount = () async { + futureChannelAllCount = ImmediateFuture.ofFuture(() async { if (!userAcc.isAuth()) throw new Exception('not logged in'); final channels = await APIClient.getChannelList(userAcc, ChannelSelector.all); return channels.length; - }(); + }()); - futureChannelSubscribedCount = () async { + futureChannelSubscribedCount = ImmediateFuture.ofFuture(() async { if (!userAcc.isAuth()) throw new Exception('not logged in'); final channels = await APIClient.getChannelList(userAcc, ChannelSelector.subscribed); return channels.length; - }(); + }()); - futureSubscriptionCount = () async { + futureSubscriptionCount = ImmediateFuture.ofFuture(() async { if (!userAcc.isAuth()) throw new Exception('not logged in'); final subs = await APIClient.getSubscriptionList(userAcc); return subs.length; - }(); + }()); - futureClientCount = () async { + futureClientCount = ImmediateFuture.ofFuture(() async { if (!userAcc.isAuth()) throw new Exception('not logged in'); final clients = await APIClient.getClientList(userAcc); return clients.length; - }(); + }()); - futureKeyCount = () async { + futureKeyCount = ImmediateFuture.ofFuture(() async { if (!userAcc.isAuth()) throw new Exception('not logged in'); final keys = await APIClient.getKeyTokenList(userAcc); return keys.length; - }(); + }()); + + futureUser = ImmediateFuture.ofFuture(userAcc.loadUser(force: false)); + } + } + + Future _backgroundRefresh() async { + if (userAcc.isAuth()) { + 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); + + // refresh all data and then replace teh futures used in build() + + final channelsAll = await APIClient.getChannelList(userAcc, ChannelSelector.all); + final channelsSubscribed = await APIClient.getChannelList(userAcc, ChannelSelector.subscribed); + final subs = await APIClient.getSubscriptionList(userAcc); + final clients = await APIClient.getClientList(userAcc); + final keys = await APIClient.getKeyTokenList(userAcc); + final user = await userAcc.loadUser(force: true); + + setState(() { + futureChannelAllCount = ImmediateFuture.ofValue(channelsAll.length); + futureChannelSubscribedCount = ImmediateFuture.ofValue(channelsSubscribed.length); + futureSubscriptionCount = ImmediateFuture.ofValue(subs.length); + futureClientCount = ImmediateFuture.ofValue(clients.length); + futureKeyCount = ImmediateFuture.ofValue(keys.length); + futureUser = ImmediateFuture.ofValue(user); + }); + } catch (exc, trace) { + ApplicationLog.error('Failed to refresh account data: ' + exc.toString(), trace: trace); + Toaster.error("Error", 'Failed to refresh account data'); + } finally { + AppBarState().setLoadingIndeterminate(false); + } } } @@ -121,15 +166,17 @@ class _AccountRootPageState extends State { return _buildNoAuth(context); } else { return FutureBuilder( - future: acc.loadUser(force: false), + future: futureUser!.future, builder: ((context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - if (snapshot.hasError) { - return Text('Error: ${snapshot.error}'); //TODO better error display - } + if (futureUser?.value != null) { + return _buildShowAccount(context, acc, futureUser!.value!); + } else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) { + return Text('Error: ${snapshot.error}'); //TODO better error display + } else if (snapshot.connectionState == ConnectionState.done) { return _buildShowAccount(context, acc, snapshot.data!); + } else { + return Center(child: CircularProgressIndicator()); } - return Center(child: CircularProgressIndicator()); }), ); } @@ -281,12 +328,15 @@ class _AccountRootPageState extends State { children: [ SizedBox(width: 80, child: Text("Channels", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)))), FutureBuilder( - future: futureChannelAllCount, + future: futureChannelAllCount!.future, builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { + if (futureChannelAllCount?.value != null) { + return Text('${futureChannelAllCount!.value}'); + } else if (snapshot.connectionState == ConnectionState.done) { return Text('${snapshot.data}'); + } else { + return const SizedBox(width: 8, height: 8, child: Center(child: CircularProgressIndicator())); } - return const SizedBox(width: 8, height: 8, child: Center(child: CircularProgressIndicator())); }, ) ], @@ -315,86 +365,10 @@ class _AccountRootPageState extends State { List _buildCards(BuildContext context, User user) { return [ - UI.buttonCard( - context: context, - margin: EdgeInsets.fromLTRB(0, 4, 0, 4), - child: Row( - children: [ - FutureBuilder( - future: futureSubscriptionCount, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - return Text('${snapshot.data}', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)); - } - return const SizedBox(width: 12, height: 12, child: Center(child: CircularProgressIndicator())); - }, - ), - const SizedBox(width: 12), - Text('Subscriptions', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)), - ], - ), - onTap: () {/*TODO*/}, - ), - UI.buttonCard( - context: context, - margin: EdgeInsets.fromLTRB(0, 4, 0, 4), - child: Row( - children: [ - FutureBuilder( - future: futureClientCount, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - return Text('${snapshot.data}', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)); - } - return const SizedBox(width: 12, height: 12, child: Center(child: CircularProgressIndicator())); - }, - ), - const SizedBox(width: 12), - Text('Clients', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)), - ], - ), - onTap: () {/*TODO*/}, - ), - UI.buttonCard( - context: context, - margin: EdgeInsets.fromLTRB(0, 4, 0, 4), - child: Row( - children: [ - FutureBuilder( - future: futureKeyCount, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - return Text('${snapshot.data}', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)); - } - return const SizedBox(width: 12, height: 12, child: Center(child: CircularProgressIndicator())); - }, - ), - const SizedBox(width: 12), - Text('Keys', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)), - ], - ), - onTap: () {/*TODO*/}, - ), - UI.buttonCard( - context: context, - margin: EdgeInsets.fromLTRB(0, 4, 0, 4), - child: Row( - children: [ - FutureBuilder( - future: futureChannelSubscribedCount, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - return Text('${snapshot.data}', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)); - } - return const SizedBox(width: 12, height: 12, child: Center(child: CircularProgressIndicator())); - }, - ), - const SizedBox(width: 12), - Text('Channels', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)), - ], - ), - onTap: () {/*TODO*/}, - ), + _buildNumberCard(context, 'Subscriptions', futureSubscriptionCount, () {/*TODO*/}), + _buildNumberCard(context, 'Clients', futureClientCount, () {/*TODO*/}), + _buildNumberCard(context, 'Keys', futureKeyCount, () {/*TODO*/}), + _buildNumberCard(context, 'Channels', futureChannelSubscribedCount, () {/*TODO*/}), UI.buttonCard( context: context, margin: EdgeInsets.fromLTRB(0, 4, 0, 4), @@ -410,6 +384,32 @@ class _AccountRootPageState extends State { ]; } + Widget _buildNumberCard(BuildContext context, String txt, ImmediateFuture? future, void Function() action) { + return UI.buttonCard( + context: context, + margin: EdgeInsets.fromLTRB(0, 4, 0, 4), + child: Row( + children: [ + FutureBuilder( + future: future?.future, + builder: (context, snapshot) { + if (future?.value != null) { + return Text('${future?.value}', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)); + } else if (snapshot.connectionState == ConnectionState.done) { + return Text('${snapshot.data}', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)); + } else { + return const SizedBox(width: 12, height: 12, child: Center(child: CircularProgressIndicator())); + } + }, + ), + const SizedBox(width: 12), + Text(txt, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)), + ], + ), + onTap: action, + ); + } + Widget _buildFooter(BuildContext context, User user) { return Padding( padding: const EdgeInsets.fromLTRB(8, 0, 8, 0), diff --git a/flutter/lib/pages/channel_list/channel_list.dart b/flutter/lib/pages/channel_list/channel_list.dart index 1ab5c47..fa0455f 100644 --- a/flutter/lib/pages/channel_list/channel_list.dart +++ b/flutter/lib/pages/channel_list/channel_list.dart @@ -3,6 +3,7 @@ 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/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/pages/channel_list/channel_list_item.dart'; @@ -27,11 +28,12 @@ class _ChannelRootPageState extends State { _pagingController.addPageRequestListener(_fetchPage); - if (widget.isVisiblePage && !_isInitialized) realInitState(); + if (widget.isVisiblePage && !_isInitialized) _realInitState(); } @override void dispose() { + ApplicationLog.debug('ChannelRootPage::dispose'); _pagingController.dispose(); super.dispose(); } @@ -42,14 +44,15 @@ class _ChannelRootPageState extends State { if (oldWidget.isVisiblePage != widget.isVisiblePage && widget.isVisiblePage) { if (!_isInitialized) { - realInitState(); + _realInitState(); } else { - //TODO background refresh + _backgroundRefresh(); } } } - void realInitState() { + void _realInitState() { + ApplicationLog.debug('ChannelRootPage::_realInitState'); _pagingController.refresh(); _isInitialized = true; } @@ -69,13 +72,41 @@ class _ChannelRootPageState extends State { items.sort((a, b) => -1 * (a.timestampLastSent ?? '').compareTo(b.timestampLastSent ?? '')); - _pagingController.appendLastPage(items); + _pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null); } catch (exc, trace) { _pagingController.error = exc.toString(); ApplicationLog.error('Failed to list channels: ' + exc.toString(), trace: trace); } } + Future _backgroundRefresh() async { + final acc = Provider.of(context, listen: false); + + ApplicationLog.debug('Start background refresh of channel list'); + + if (!acc.isAuth()) { + _pagingController.error = 'Not logged in'; + return; + } + + 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); + + final items = (await APIClient.getChannelList(acc, ChannelSelector.all)).map((p) => p.channel).toList(); + + items.sort((a, b) => -1 * (a.timestampLastSent ?? '').compareTo(b.timestampLastSent ?? '')); + + _pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null); + } catch (exc, trace) { + _pagingController.error = exc.toString(); + ApplicationLog.error('Failed to list channels: ' + exc.toString(), trace: trace); + } finally { + AppBarState().setLoadingIndeterminate(false); + } + } + @override Widget build(BuildContext context) { return RefreshIndicator( diff --git a/flutter/lib/pages/debug/debug_persistence.dart b/flutter/lib/pages/debug/debug_persistence.dart index 15b8cfe..e02a52f 100644 --- a/flutter/lib/pages/debug/debug_persistence.dart +++ b/flutter/lib/pages/debug/debug_persistence.dart @@ -43,7 +43,7 @@ class _DebugPersistencePageState extends State { children: [ SizedBox(width: 30, child: Text('')), Expanded(child: Text('Shared Preferences', style: TextStyle(fontWeight: FontWeight.bold), textAlign: TextAlign.center)), - SizedBox(width: 30, child: Text('${prefs?.getKeys().length.toString()}', textAlign: TextAlign.end)), + SizedBox(width: 40, child: Text('${prefs?.getKeys().length.toString()}', textAlign: TextAlign.end)), ], ), ), @@ -61,7 +61,7 @@ class _DebugPersistencePageState extends State { children: [ SizedBox(width: 30, child: Text('')), Expanded(child: Text('Hive [scn-requests]', style: TextStyle(fontWeight: FontWeight.bold), textAlign: TextAlign.center)), - SizedBox(width: 30, child: Text('${Hive.box('scn-requests').length.toString()}', textAlign: TextAlign.end)), + SizedBox(width: 40, child: Text('${Hive.box('scn-requests').length.toString()}', textAlign: TextAlign.end)), ], ), ), @@ -79,7 +79,7 @@ class _DebugPersistencePageState extends State { children: [ SizedBox(width: 30, child: Text('')), Expanded(child: Text('Hive [scn-logs]', style: TextStyle(fontWeight: FontWeight.bold), textAlign: TextAlign.center)), - SizedBox(width: 30, child: Text('${Hive.box('scn-logs').length.toString()}', textAlign: TextAlign.end)), + SizedBox(width: 40, child: Text('${Hive.box('scn-logs').length.toString()}', textAlign: TextAlign.end)), ], ), ), @@ -97,7 +97,7 @@ class _DebugPersistencePageState extends State { 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)), + SizedBox(width: 40, child: Text('${Hive.box('scn-message-cache').length.toString()}', textAlign: TextAlign.end)), ], ), ), @@ -115,7 +115,7 @@ class _DebugPersistencePageState extends State { 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)), + SizedBox(width: 40, 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 e628092..44fca48 100644 --- a/flutter/lib/pages/message_list/message_list.dart +++ b/flutter/lib/pages/message_list/message_list.dart @@ -39,7 +39,7 @@ class _MessageListPageState extends State with RouteAware { _pagingController.addPageRequestListener(_fetchPage); - if (widget.isVisiblePage && !_isInitialized) realInitState(); + if (widget.isVisiblePage && !_isInitialized) _realInitState(); } @override @@ -48,14 +48,16 @@ class _MessageListPageState extends State with RouteAware { if (oldWidget.isVisiblePage != widget.isVisiblePage && widget.isVisiblePage) { if (!_isInitialized) { - realInitState(); + _realInitState(); } else { _backgroundRefresh(false); } } } - void realInitState() { + void _realInitState() { + ApplicationLog.debug('MessageListPage::_realInitState'); + final chnCache = Hive.box('scn-channel-cache'); final msgCache = Hive.box('scn-message-cache'); @@ -86,6 +88,7 @@ class _MessageListPageState extends State with RouteAware { @override void dispose() { + ApplicationLog.debug('MessageListPage::dispose'); Navi.modalRouteObserver.unsubscribe(this); _pagingController.dispose(); super.dispose(); @@ -93,14 +96,13 @@ class _MessageListPageState extends State with RouteAware { @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'); + ApplicationLog.debug('[MessageList::RouteObserver] --> didPopNext (will background-refresh)'); + _backgroundRefresh(false); } Future _fetchPage(String thisPageToken) async { diff --git a/flutter/lib/state/app_auth.dart b/flutter/lib/state/app_auth.dart index 109520c..a161485 100644 --- a/flutter/lib/state/app_auth.dart +++ b/flutter/lib/state/app_auth.dart @@ -118,7 +118,6 @@ class AppAuth extends ChangeNotifier implements TokenSource { final user = await APIClient.getUser(this, _userID!); _user = user; - notifyListeners(); await save(); @@ -142,14 +141,12 @@ class AppAuth extends ChangeNotifier implements TokenSource { final client = await APIClient.getClient(this, _clientID!); _client = client; - notifyListeners(); await save(); return client; } on APIException catch (_) { _client = null; - notifyListeners(); return null; } catch (exc) { _client = null; diff --git a/flutter/lib/state/application_log.dart b/flutter/lib/state/application_log.dart index 15892fc..d997a7a 100644 --- a/flutter/lib/state/application_log.dart +++ b/flutter/lib/state/application_log.dart @@ -5,6 +5,8 @@ import 'package:xid/xid.dart'; part 'application_log.g.dart'; class ApplicationLog { + //TODO max size, auto clear old + static void debug(String message, {String? additional, StackTrace? trace}) { (additional != null && additional != '') ? print('[DEBUG] ${message}: ${additional}') : print('[DEBUG] ${message}'); diff --git a/flutter/lib/state/request_log.dart b/flutter/lib/state/request_log.dart index 28d404f..233fe68 100644 --- a/flutter/lib/state/request_log.dart +++ b/flutter/lib/state/request_log.dart @@ -6,6 +6,8 @@ import 'package:xid/xid.dart'; part 'request_log.g.dart'; class RequestLog { + //TODO max size, auto clear old + static void addRequestException(String name, DateTime tStart, String method, Uri uri, String reqbody, Map reqheaders, dynamic e, StackTrace trace) { Hive.box('scn-requests').add(SCNRequest( id: Xid().toString(), diff --git a/flutter/lib/types/immediate_future.dart b/flutter/lib/types/immediate_future.dart new file mode 100644 index 0000000..7953e46 --- /dev/null +++ b/flutter/lib/types/immediate_future.dart @@ -0,0 +1,18 @@ +// This class is useful togther with FutureBuilder +// Unfortunately Future.value(x) in FutureBuilder always results in one frame were snapshot.connectionState is waiting +// Whit way we can set the ImmediateFuture.value directly and circumvent that. + +class ImmediateFuture { + final Future future; + final T? value; + + ImmediateFuture(this.future, this.value); + + ImmediateFuture.ofFuture(Future v) + : future = v, + value = null; + + ImmediateFuture.ofValue(T v) + : future = Future.value(v), + value = v; +}