Cache messages, use cache if exists, load in background

This commit is contained in:
Mike Schwörer 2024-06-15 15:56:50 +02:00
parent 9c366399df
commit 35ab9a26c0
Signed by: Mikescher
GPG Key ID: D3C7172E0A70F8CF
16 changed files with 556 additions and 42 deletions

View File

@ -1,7 +1,7 @@
run: run:
dart run build_runner build flutter pub run build_runner build
flutter run flutter run
test: test:
@ -11,7 +11,7 @@ fix:
dart fix --apply dart fix --apply
gen: gen:
dart run build_runner build flutter pub run build_runner build
autoreload: autoreload:
@# run `make run` in another terminal (or another variant of flutter run) @# run `make run` in another terminal (or another variant of flutter run)

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/components/layout/app_bar_progress_indicator.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) { Widget build(BuildContext context) {
var actions = <Widget>[]; var actions = <Widget>[];
if (showDebug) {
actions.add(IconButton(
icon: const Icon(FontAwesomeIcons.solidSpiderBlackWidow),
tooltip: 'Debug',
onPressed: () {
Navi.push(context, () => DebugMainPage());
},
));
}
if (showThemeSwitch) { if (showThemeSwitch) {
actions.add(Consumer<AppTheme>( actions.add(Consumer<AppTheme>(
builder: (context, appTheme, child) => IconButton( builder: (context, appTheme, child) => IconButton(
@ -37,19 +48,16 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget {
), ),
)); ));
} else { } else {
actions.add(SizedBox.square(dimension: 40)); actions.add(Visibility(
} visible: false,
maintainSize: true,
if (showDebug) { maintainAnimation: true,
actions.add(IconButton( maintainState: true,
icon: const Icon(FontAwesomeIcons.solidSpiderBlackWidow), child: IconButton(
tooltip: 'Debug', icon: const Icon(FontAwesomeIcons.square),
onPressed: () { onPressed: () {/*TODO*/},
Navi.push(context, () => DebugMainPage()); ),
},
)); ));
} else {
actions.add(SizedBox.square(dimension: 40));
} }
if (showSearch) { if (showSearch) {
@ -65,7 +73,16 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget {
onPressed: onShare ?? () {}, onPressed: onShare ?? () {},
)); ));
} else { } 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( return AppBar(

View File

@ -5,7 +5,9 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/client.dart'; import 'package:simplecloudnotifier/models/client.dart';
import 'package:simplecloudnotifier/models/message.dart';
import 'package:simplecloudnotifier/nav_layout.dart'; import 'package:simplecloudnotifier/nav_layout.dart';
import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart';
import 'package:simplecloudnotifier/state/app_theme.dart'; import 'package:simplecloudnotifier/state/app_theme.dart';
@ -36,6 +38,8 @@ void main() async {
Hive.registerAdapter(SCNRequestAdapter()); Hive.registerAdapter(SCNRequestAdapter());
Hive.registerAdapter(SCNLogAdapter()); Hive.registerAdapter(SCNLogAdapter());
Hive.registerAdapter(SCNLogLevelAdapter()); Hive.registerAdapter(SCNLogLevelAdapter());
Hive.registerAdapter(MessageAdapter());
Hive.registerAdapter(ChannelAdapter());
print('[INIT] Load Hive<scn-requests>...'); print('[INIT] Load Hive<scn-requests>...');
@ -57,6 +61,26 @@ void main() async {
ApplicationLog.error('Failed to open Hive-Box: scn-logs: ' + exc.toString(), trace: trace); ApplicationLog.error('Failed to open Hive-Box: scn-logs: ' + exc.toString(), trace: trace);
} }
print('[INIT] Load Hive<scn-message-cache>...');
try {
await Hive.openBox<Message>('scn-message-cache');
} catch (exc, trace) {
Hive.deleteBoxFromDisk('scn-message-cache');
await Hive.openBox<Message>('scn-message-cache');
ApplicationLog.error('Failed to open Hive-Box: scn-message-cache' + exc.toString(), trace: trace);
}
print('[INIT] Load Hive<scn-channel-cache>...');
try {
await Hive.openBox<Channel>('scn-channel-cache');
} catch (exc, trace) {
Hive.deleteBoxFromDisk('scn-channel-cache');
await Hive.openBox<Channel>('scn-channel-cache');
ApplicationLog.error('Failed to open Hive-Box: scn-channel-cache' + exc.toString(), trace: trace);
}
print('[INIT] Load AppAuth...'); print('[INIT] Load AppAuth...');
final appAuth = AppAuth(); // ensure UserAccount is loaded final appAuth = AppAuth(); // ensure UserAccount is loaded
@ -135,7 +159,7 @@ void setFirebaseToken(String fcmToken) async {
Client? client; Client? client;
try { try {
client = await acc.loadClient(force: true); client = await acc.loadClient(forceIfOlder: Duration(seconds: 60));
} catch (exc, trace) { } catch (exc, trace) {
ApplicationLog.error('Failed to get client: ' + exc.toString(), trace: trace); ApplicationLog.error('Failed to get client: ' + exc.toString(), trace: trace);
return; return;
@ -172,7 +196,7 @@ class SCNApp extends StatelessWidget {
child: Consumer<AppTheme>( child: Consumer<AppTheme>(
builder: (context, appTheme, child) => MaterialApp( builder: (context, appTheme, child) => MaterialApp(
title: 'SimpleCloudNotifier', title: 'SimpleCloudNotifier',
navigatorObservers: [Navi.routeObserver], navigatorObservers: [Navi.routeObserver, Navi.modalRouteObserver],
theme: ThemeData( theme: ThemeData(
//TODO color settings //TODO color settings
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue, brightness: appTheme.darkMode ? Brightness.dark : Brightness.light), colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue, brightness: appTheme.darkMode ? Brightness.dark : Brightness.light),

View File

@ -1,17 +1,32 @@
import 'package:hive_flutter/hive_flutter.dart';
import 'package:simplecloudnotifier/models/subscription.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; final String channelID;
@HiveField(10)
final String ownerUserID; final String ownerUserID;
@HiveField(11)
final String internalName; final String internalName;
@HiveField(12)
final String displayName; final String displayName;
@HiveField(13)
final String? descriptionName; final String? descriptionName;
@HiveField(14)
final String? subscribeKey; final String? subscribeKey;
@HiveField(15)
final String timestampCreated; final String timestampCreated;
@HiveField(16)
final String? timestampLastSent; final String? timestampLastSent;
@HiveField(17)
final int messagesSent; final int messagesSent;
const Channel({ Channel({
required this.channelID, required this.channelID,
required this.ownerUserID, required this.ownerUserID,
required this.internalName, required this.internalName,
@ -36,6 +51,25 @@ class Channel {
messagesSent: json['messages_sent'] as int, 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 { class ChannelWithSubscription {

View File

@ -0,0 +1,65 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'channel.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class ChannelAdapter extends TypeAdapter<Channel> {
@override
final int typeId = 104;
@override
Channel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
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;
}

View File

@ -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; final String messageID;
@HiveField(10)
final String senderUserID; final String senderUserID;
@HiveField(11)
final String channelInternalName; final String channelInternalName;
@HiveField(12)
final String channelID; final String channelID;
@HiveField(13)
final String? senderName; final String? senderName;
@HiveField(14)
final String senderIP; final String senderIP;
@HiveField(15)
final String timestamp; final String timestamp;
@HiveField(16)
final String title; final String title;
@HiveField(17)
final String? content; final String? content;
@HiveField(18)
final int priority; final int priority;
@HiveField(19)
final String? userMessageID; final String? userMessageID;
@HiveField(20)
final String usedKeyID; final String usedKeyID;
@HiveField(21)
final bool trimmed; final bool trimmed;
const Message({ Message({
required this.messageID, required this.messageID,
required this.senderUserID, required this.senderUserID,
required this.channelInternalName, required this.channelInternalName,
@ -54,4 +74,27 @@ class Message {
return (npt, messages); 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}'),
];
}
} }

View File

@ -0,0 +1,77 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'message.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class MessageAdapter extends TypeAdapter<Message> {
@override
final int typeId = 105;
@override
Message read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
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;
}

View File

@ -66,11 +66,11 @@ class _SCNNavLayoutState extends State<SCNNavLayout> {
), ),
body: IndexedStack( body: IndexedStack(
children: [ children: [
ExcludeFocus(excluding: _selectedIndex != 0, child: MessageListPage()), ExcludeFocus(excluding: _selectedIndex != 0, child: MessageListPage(isVisiblePage: _selectedIndex == 0)),
ExcludeFocus(excluding: _selectedIndex != 1, child: ChannelRootPage()), ExcludeFocus(excluding: _selectedIndex != 1, child: ChannelRootPage(isVisiblePage: _selectedIndex == 1)),
ExcludeFocus(excluding: _selectedIndex != 2, child: AccountRootPage()), ExcludeFocus(excluding: _selectedIndex != 2, child: AccountRootPage(isVisiblePage: _selectedIndex == 2)),
ExcludeFocus(excluding: _selectedIndex != 3, child: SettingsRootPage()), ExcludeFocus(excluding: _selectedIndex != 3, child: SettingsRootPage(isVisiblePage: _selectedIndex == 3)),
ExcludeFocus(excluding: _selectedIndex != 4, child: SendRootPage()), ExcludeFocus(excluding: _selectedIndex != 4, child: SendRootPage(isVisiblePage: _selectedIndex == 4)),
], ],
index: _selectedIndex, index: _selectedIndex,
), ),

View File

@ -16,7 +16,9 @@ import 'package:simplecloudnotifier/utils/ui.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
class AccountRootPage extends StatefulWidget { class AccountRootPage extends StatefulWidget {
const AccountRootPage({super.key}); const AccountRootPage({super.key, required this.isVisiblePage});
final bool isVisiblePage;
@override @override
State<AccountRootPage> createState() => _AccountRootPageState(); State<AccountRootPage> createState() => _AccountRootPageState();
@ -33,13 +35,34 @@ class _AccountRootPageState extends State<AccountRootPage> {
bool loading = false; bool loading = false;
bool _isInitialized = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
userAcc = Provider.of<AppAuth>(context, listen: false); userAcc = Provider.of<AppAuth>(context, listen: false);
userAcc.addListener(_onAuthStateChanged); 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(); _onAuthStateChanged();
_isInitialized = true;
} }
@override @override
@ -92,6 +115,8 @@ class _AccountRootPageState extends State<AccountRootPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<AppAuth>( return Consumer<AppAuth>(
builder: (context, acc, child) { builder: (context, acc, child) {
if (!_isInitialized) return SizedBox();
if (!userAcc.isAuth()) { if (!userAcc.isAuth()) {
return _buildNoAuth(context); return _buildNoAuth(context);
} else { } else {

View File

@ -8,21 +8,26 @@ import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/pages/channel_list/channel_list_item.dart'; import 'package:simplecloudnotifier/pages/channel_list/channel_list_item.dart';
class ChannelRootPage extends StatefulWidget { class ChannelRootPage extends StatefulWidget {
const ChannelRootPage({super.key}); const ChannelRootPage({super.key, required this.isVisiblePage});
final bool isVisiblePage;
@override @override
State<ChannelRootPage> createState() => _ChannelRootPageState(); State<ChannelRootPage> createState() => _ChannelRootPageState();
} }
class _ChannelRootPageState extends State<ChannelRootPage> { class _ChannelRootPageState extends State<ChannelRootPage> {
final PagingController<int, Channel> _pagingController = PagingController(firstPageKey: 0); final PagingController<int, Channel> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: 0);
bool _isInitialized = false;
@override @override
void initState() { void initState() {
_pagingController.addPageRequestListener((pageKey) {
_fetchPage(pageKey);
});
super.initState(); super.initState();
_pagingController.addPageRequestListener(_fetchPage);
if (widget.isVisiblePage && !_isInitialized) realInitState();
} }
@override @override
@ -31,9 +36,29 @@ class _ChannelRootPageState extends State<ChannelRootPage> {
super.dispose(); 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<void> _fetchPage(int pageKey) async { Future<void> _fetchPage(int pageKey) async {
final acc = Provider.of<AppAuth>(context, listen: false); final acc = Provider.of<AppAuth>(context, listen: false);
ApplicationLog.debug('Start ChannelList::_pagingController::_fetchPage [ ${pageKey} ]');
if (!acc.isAuth()) { if (!acc.isAuth()) {
_pagingController.error = 'Not logged in'; _pagingController.error = 'Not logged in';
return; return;

View File

@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:shared_preferences/shared_preferences.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_hive.dart';
import 'package:simplecloudnotifier/pages/debug/debug_persistence_sharedprefs.dart'; import 'package:simplecloudnotifier/pages/debug/debug_persistence_sharedprefs.dart';
import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/application_log.dart';
@ -83,6 +85,42 @@ class _DebugPersistencePageState extends State<DebugPersistencePage> {
), ),
), ),
), ),
Card.outlined(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: GestureDetector(
onTap: () {
Navi.push(context, () => DebugHiveBoxPage(boxName: 'scn-message-cache', box: Hive.box<Message>('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<Message>('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<Channel>('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<Channel>('scn-channel-cache').length.toString()}', textAlign: TextAlign.end)),
],
),
),
),
),
], ],
), ),
); );

View File

@ -1,17 +1,21 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/api/api_client.dart';
import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/channel.dart';
import 'package:simplecloudnotifier/models/message.dart'; import 'package:simplecloudnotifier/models/message.dart';
import 'package:simplecloudnotifier/pages/message_view/message_view.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/application_log.dart';
import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_auth.dart';
import 'package:simplecloudnotifier/pages/message_list/message_list_item.dart'; import 'package:simplecloudnotifier/pages/message_list/message_list_item.dart';
import 'package:simplecloudnotifier/utils/navi.dart'; import 'package:simplecloudnotifier/utils/navi.dart';
class MessageListPage extends StatefulWidget { 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 switch to tab
//TODO reload on app to foreground //TODO reload on app to foreground
@ -20,31 +24,90 @@ class MessageListPage extends StatefulWidget {
State<MessageListPage> createState() => _MessageListPageState(); State<MessageListPage> createState() => _MessageListPageState();
} }
class _MessageListPageState extends State<MessageListPage> { class _MessageListPageState extends State<MessageListPage> with RouteAware {
static const _pageSize = 128; static const _pageSize = 128;
final PagingController<String, Message> _pagingController = PagingController(firstPageKey: '@start'); PagingController<String, Message> _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: '@start');
Map<String, Channel>? _channels = null; Map<String, Channel>? _channels = null;
bool _isInitialized = false;
@override @override
void initState() { void initState() {
//TODO init with state from cache - also allow tho show cache on error
_pagingController.addPageRequestListener((pageKey) {
_fetchPage(pageKey);
});
super.initState(); 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<Channel>('scn-channel-cache');
final msgCache = Hive.box<Message>('scn-message-cache');
if (chnCache.isNotEmpty && msgCache.isNotEmpty) {
// ==== Use cache values - and refresh in background
_channels = <String, Channel>{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 @override
void dispose() { void dispose() {
Navi.modalRouteObserver.unsubscribe(this);
_pagingController.dispose(); _pagingController.dispose();
super.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<void> _fetchPage(String thisPageToken) async { Future<void> _fetchPage(String thisPageToken) async {
final acc = Provider.of<AppAuth>(context, listen: false); final acc = Provider.of<AppAuth>(context, listen: false);
ApplicationLog.debug('Start MessageList::_pagingController::_fetchPage [ ${thisPageToken} ]');
if (!acc.isAuth()) { if (!acc.isAuth()) {
_pagingController.error = 'Not logged in'; _pagingController.error = 'Not logged in';
return; return;
@ -54,10 +117,16 @@ class _MessageListPageState extends State<MessageListPage> {
if (_channels == null) { if (_channels == null) {
final channels = await APIClient.getChannelList(acc, ChannelSelector.allAny); final channels = await APIClient.getChannelList(acc, ChannelSelector.allAny);
_channels = <String, Channel>{for (var v in channels) v.channel.channelID: v.channel}; _channels = <String, Channel>{for (var v in channels) v.channel.channelID: v.channel};
_setChannelCache(channels); // no await
} }
final (npt, newItems) = await APIClient.getMessageList(acc, thisPageToken, pageSize: _pageSize); 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') { if (npt == '@end') {
_pagingController.appendLastPage(newItems); _pagingController.appendLastPage(newItems);
} else { } else {
@ -69,6 +138,71 @@ class _MessageListPageState extends State<MessageListPage> {
} }
} }
Future<void> _backgroundRefresh(bool fullReplaceState) async {
final acc = Provider.of<AppAuth>(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 = <String, Channel>{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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
@ -92,4 +226,30 @@ class _MessageListPageState extends State<MessageListPage> {
), ),
); );
} }
Future<void> _setChannelCache(List<ChannelWithSubscription> channels) async {
final cache = Hive.box<Channel>('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<void> _addToMessageCache(List<Message> newItems) async {
final cache = Hive.box<Message>('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);
}
}
} }

View File

@ -6,7 +6,7 @@ import 'package:url_launcher/url_launcher.dart';
import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_auth.dart';
class SendRootPage extends StatefulWidget { class SendRootPage extends StatefulWidget {
const SendRootPage({super.key}); const SendRootPage({super.key, required bool isVisiblePage});
@override @override
State<SendRootPage> createState() => _SendRootPageState(); State<SendRootPage> createState() => _SendRootPageState();

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class SettingsRootPage extends StatefulWidget { class SettingsRootPage extends StatefulWidget {
const SettingsRootPage({super.key}); const SettingsRootPage({super.key, required bool isVisiblePage});
@override @override
State<SettingsRootPage> createState() => _SettingsRootPageState(); State<SettingsRootPage> createState() => _SettingsRootPageState();

View File

@ -14,6 +14,7 @@ class AppAuth extends ChangeNotifier implements TokenSource {
User? _user; User? _user;
Client? _client; Client? _client;
DateTime? _clientQueryTime;
String? get userID => _userID; String? get userID => _userID;
String? get tokenAdmin => _tokenAdmin; String? get tokenAdmin => _tokenAdmin;
@ -124,7 +125,11 @@ class AppAuth extends ChangeNotifier implements TokenSource {
return user; return user;
} }
Future<Client?> loadClient({bool force = false}) async { Future<Client?> 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) { if (!force && _client != null && _client!.clientID == _clientID) {
return _client!; return _client!;
} }

View File

@ -4,6 +4,7 @@ import 'package:simplecloudnotifier/state/app_bar_state.dart';
class Navi { class Navi {
static final SCNRouteObserver routeObserver = SCNRouteObserver(); static final SCNRouteObserver routeObserver = SCNRouteObserver();
static final RouteObserver<ModalRoute<void>> modalRouteObserver = RouteObserver<ModalRoute<void>>();
static void push<T extends Widget>(BuildContext context, T Function() builder) { static void push<T extends Widget>(BuildContext context, T Function() builder) {
Provider.of<AppBarState>(context, listen: false).setLoadingIndeterminate(false); Provider.of<AppBarState>(context, listen: false).setLoadingIndeterminate(false);