diff --git a/flutter/Makefile b/flutter/Makefile index e2abffc..c710369 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,8 +11,11 @@ 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) - @_utils/autoreload.sh \ No newline at end of file + @_utils/autoreload.sh + +icons: + flutter pub run flutter_launcher_icons -f "flutter_launcher_icons.yaml" \ No newline at end of file diff --git a/flutter/android/app/src/main/AndroidManifest.xml b/flutter/android/app/src/main/AndroidManifest.xml index db8477b..629c8ae 100644 --- a/flutter/android/app/src/main/AndroidManifest.xml +++ b/flutter/android/app/src/main/AndroidManifest.xml @@ -9,7 +9,7 @@ + android:icon="@mipmap/launcher_icon"> getUserPreview(TokenSource auth, String uid) async { + return await _request( + name: 'getUserPreview', + method: 'GET', + relURL: 'preview/users/$uid', + fn: UserPreview.fromJson, + authToken: auth.getToken(), + ); + } + static Future addClient(TokenSource auth, String fcmToken, String agentModel, String agentVersion, String? name, String clientType) async { return await _request( name: 'addClient', @@ -191,6 +201,16 @@ class APIClient { ); } + static Future getChannelPreview(TokenSource auth, String cid) async { + return await _request( + name: 'getChannelPreview', + method: 'GET', + relURL: 'preview/channels/${cid}', + fn: ChannelPreview.fromJson, + authToken: auth.getToken(), + ); + } + static Future<(String, List)> getMessageList(TokenSource auth, String pageToken, {int? pageSize, List? channelIDs}) async { return await _request( name: 'getMessageList', @@ -275,6 +295,16 @@ class APIClient { ); } + static Future getKeyTokenPreview(TokenSource auth, String kid) async { + return await _request( + name: 'getKeyTokenPreview', + method: 'GET', + relURL: 'preview/keys/$kid', + fn: KeyTokenPreview.fromJson, + authToken: auth.getToken(), + ); + } + static Future getKeyTokenByToken(String userid, String token) async { return await _request( name: 'getCurrentKeyToken', diff --git a/flutter/lib/components/layout/app_bar.dart b/flutter/lib/components/layout/app_bar.dart index 93d3e95..165e0fc 100644 --- a/flutter/lib/components/layout/app_bar.dart +++ b/flutter/lib/components/layout/app_bar.dart @@ -1,8 +1,11 @@ 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'; import 'package:simplecloudnotifier/pages/debug/debug_main.dart'; import 'package:simplecloudnotifier/state/app_theme.dart'; +import 'package:simplecloudnotifier/utils/navi.dart'; class SCNAppBar extends StatelessWidget implements PreferredSizeWidget { const SCNAppBar({ @@ -26,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( @@ -35,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: () { - Navigator.push(context, MaterialPageRoute(builder: (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) { @@ -63,13 +73,26 @@ 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( title: Text(title ?? 'Simple Cloud Notifier 2.0'), actions: actions, backgroundColor: Theme.of(context).secondaryHeaderColor, + bottom: PreferredSize( + preferredSize: Size(double.infinity, 1.0), + child: AppBarProgressIndicator(), + ), ); } diff --git a/flutter/lib/components/layout/app_bar_progress_indicator.dart b/flutter/lib/components/layout/app_bar_progress_indicator.dart new file mode 100644 index 0000000..c227ea7 --- /dev/null +++ b/flutter/lib/components/layout/app_bar_progress_indicator.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:simplecloudnotifier/state/app_bar_state.dart'; + +class AppBarProgressIndicator extends StatelessWidget implements PreferredSizeWidget { + @override + Size get preferredSize => Size(double.infinity, 1.0); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, value, child) { + if (value.loadingIndeterminate) { + return LinearProgressIndicator(value: null); + } else { + return SizedBox.square(dimension: 4); // 4 height is the same as the LinearProgressIndicator + } + }, + ); + } +} diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index 497fb8e..8c960c5 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -5,14 +5,19 @@ 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'; import 'package:simplecloudnotifier/state/application_log.dart'; +import 'package:simplecloudnotifier/state/fb_message.dart'; import 'package:simplecloudnotifier/state/globals.dart'; import 'package:simplecloudnotifier/state/request_log.dart'; import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:firebase_core/firebase_core.dart'; +import 'package:simplecloudnotifier/utils/navi.dart'; import 'package:toastification/toastification.dart'; import 'firebase_options.dart'; @@ -34,6 +39,9 @@ void main() async { Hive.registerAdapter(SCNRequestAdapter()); Hive.registerAdapter(SCNLogAdapter()); Hive.registerAdapter(SCNLogLevelAdapter()); + Hive.registerAdapter(MessageAdapter()); + Hive.registerAdapter(ChannelAdapter()); + Hive.registerAdapter(FBMessageAdapter()); print('[INIT] Load Hive...'); @@ -55,6 +63,36 @@ 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 Hive...'); + + try { + await Hive.openBox('scn-fb-messages'); + } catch (exc, trace) { + Hive.deleteBoxFromDisk('scn-fb-messages'); + await Hive.openBox('scn-fb-messages'); + ApplicationLog.error('Failed to open Hive-Box: scn-fb-messages' + exc.toString(), trace: trace); + } + print('[INIT] Load AppAuth...'); final appAuth = AppAuth(); // ensure UserAccount is loaded @@ -102,6 +140,9 @@ void main() async { } catch (exc, trace) { ApplicationLog.error('Failed to get+set firebase token: ' + exc.toString(), trace: trace); } + + FirebaseMessaging.onBackgroundMessage(_onBackgroundMessage); + FirebaseMessaging.onMessage.listen(_onForegroundMessage); } else { print('[INIT] Skip Firebase init (Platform == Linux)...'); } @@ -113,12 +154,40 @@ void main() async { providers: [ ChangeNotifierProvider(create: (context) => AppAuth(), lazy: false), ChangeNotifierProvider(create: (context) => AppTheme(), lazy: false), + ChangeNotifierProvider(create: (context) => AppBarState(), lazy: false), ], - child: const SCNApp(), + child: SCNApp(), ), ); } +class SCNApp extends StatelessWidget { + SCNApp({super.key}); + + @override + Widget build(BuildContext context) { + return ToastificationWrapper( + config: ToastificationConfig( + itemWidth: 440, + marginBuilder: (alignment) => EdgeInsets.symmetric(vertical: 64), + animationDuration: Duration(milliseconds: 200), + ), + child: Consumer( + builder: (context, appTheme, child) => MaterialApp( + title: 'SimpleCloudNotifier', + navigatorObservers: [Navi.routeObserver, Navi.modalRouteObserver], + theme: ThemeData( + //TODO color settings + colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue, brightness: appTheme.darkMode ? Brightness.dark : Brightness.light), + useMaterial3: true, + ), + home: SCNNavLayout(), + ), + ), + ); + } +} + void setFirebaseToken(String fcmToken) async { final acc = AppAuth(); @@ -132,7 +201,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; @@ -155,28 +224,18 @@ void setFirebaseToken(String fcmToken) async { } } -class SCNApp extends StatelessWidget { - const SCNApp({super.key}); - - @override - Widget build(BuildContext context) { - return ToastificationWrapper( - config: ToastificationConfig( - itemWidth: 440, - marginBuilder: (alignment) => EdgeInsets.symmetric(vertical: 64), - animationDuration: Duration(milliseconds: 200), - ), - child: Consumer( - builder: (context, appTheme, child) => MaterialApp( - title: 'SimpleCloudNotifier', - theme: ThemeData( - //TODO color settings - colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue, brightness: appTheme.darkMode ? Brightness.dark : Brightness.light), - useMaterial3: true, - ), - home: SCNNavLayout(), - ), - ), - ); - } +Future _onBackgroundMessage(RemoteMessage message) async { + await _receiveMessage(message, false); +} + +void _onForegroundMessage(RemoteMessage message) { + _receiveMessage(message, true); +} + +Future _receiveMessage(RemoteMessage message, bool foreground) async { + // ensure init + Hive.openBox('scn-logs'); + + ApplicationLog.info('Received FB message (${foreground ? 'foreground' : 'background'}): ${message.messageId ?? 'NULL'}'); + FBMessageLog.insert(message); } diff --git a/flutter/lib/models/channel.dart b/flutter/lib/models/channel.dart index 51b40c1..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 { @@ -58,3 +92,29 @@ class ChannelWithSubscription { return jsonArr.map((e) => ChannelWithSubscription.fromJson(e as Map)).toList(); } } + +class ChannelPreview { + final String channelID; + final String ownerUserID; + final String internalName; + final String displayName; + final String? descriptionName; + + const ChannelPreview({ + required this.channelID, + required this.ownerUserID, + required this.internalName, + required this.displayName, + required this.descriptionName, + }); + + factory ChannelPreview.fromJson(Map json) { + return ChannelPreview( + channelID: json['channel_id'] as String, + ownerUserID: json['owner_user_id'] as String, + internalName: json['internal_name'] as String, + displayName: json['display_name'] as String, + descriptionName: json['description_name'] as String?, + ); + } +} 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/keytoken.dart b/flutter/lib/models/keytoken.dart index c8f70a2..a2d84d5 100644 --- a/flutter/lib/models/keytoken.dart +++ b/flutter/lib/models/keytoken.dart @@ -39,3 +39,32 @@ class KeyToken { return jsonArr.map((e) => KeyToken.fromJson(e as Map)).toList(); } } + +class KeyTokenPreview { + final String keytokenID; + final String name; + final String ownerUserID; + final bool allChannels; + final List channels; + final String permissions; + + const KeyTokenPreview({ + required this.keytokenID, + required this.name, + required this.ownerUserID, + required this.allChannels, + required this.channels, + required this.permissions, + }); + + factory KeyTokenPreview.fromJson(Map json) { + return KeyTokenPreview( + keytokenID: json['keytoken_id'] as String, + name: json['name'] as String, + ownerUserID: json['owner_user_id'] as String, + allChannels: json['all_channels'] as bool, + channels: (json['channels'] as List).map((e) => e as String).toList(), + permissions: json['permissions'] as String, + ); + } +} 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/models/user.dart b/flutter/lib/models/user.dart index 4a9b140..58bd769 100644 --- a/flutter/lib/models/user.dart +++ b/flutter/lib/models/user.dart @@ -90,3 +90,20 @@ class UserWithClientsAndKeys { ); } } + +class UserPreview { + final String userID; + final String? username; + + const UserPreview({ + required this.userID, + required this.username, + }); + + factory UserPreview.fromJson(Map json) { + return UserPreview( + userID: json['user_id'] as String, + username: json['username'] as String?, + ); + } +} 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 72bd378..6074f6c 100644 --- a/flutter/lib/pages/account/account.dart +++ b/flutter/lib/pages/account/account.dart @@ -7,47 +7,81 @@ 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'; 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(); } 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; 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 { + _backgroundRefresh(); + } + } + } + + 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; @@ -55,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); + } } } @@ -91,19 +160,23 @@ 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 { 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()); }), ); } @@ -157,7 +230,7 @@ class _AccountRootPageState extends State { text: 'Use existing account', onPressed: () { if (loading) return; - Navigator.push(context, MaterialPageRoute(builder: (context) => AccountLoginPage())); + Navi.push(context, () => AccountLoginPage()); }, tonal: true, big: true, @@ -255,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())); }, ) ], @@ -289,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), @@ -384,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/account/login.dart b/flutter/lib/pages/account/login.dart index 2711ce0..9d9f76d 100644 --- a/flutter/lib/pages/account/login.dart +++ b/flutter/lib/pages/account/login.dart @@ -8,6 +8,7 @@ import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/globals.dart'; import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/token_source.dart'; +import 'package:simplecloudnotifier/utils/navi.dart'; import 'package:simplecloudnotifier/utils/toaster.dart'; import 'package:simplecloudnotifier/utils/ui.dart'; @@ -154,7 +155,7 @@ class _AccountLoginPageState extends State { await acc.save(); Toaster.success("Login", "Successfully logged in"); - Navigator.popUntil(context, (route) => route.isFirst); + Navi.popToRoot(context); } catch (exc, trace) { ApplicationLog.error('Failed to verify token: ' + exc.toString(), trace: trace); Toaster.error("Error", 'Failed to verify token'); diff --git a/flutter/lib/pages/channel_list/channel_list.dart b/flutter/lib/pages/channel_list/channel_list.dart index 3c70919..fa0455f 100644 --- a/flutter/lib/pages/channel_list/channel_list.dart +++ b/flutter/lib/pages/channel_list/channel_list.dart @@ -3,37 +3,65 @@ 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'; 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 void dispose() { + ApplicationLog.debug('ChannelRootPage::dispose'); _pagingController.dispose(); super.dispose(); } + @override + void didUpdateWidget(ChannelRootPage oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.isVisiblePage != widget.isVisiblePage && widget.isVisiblePage) { + if (!_isInitialized) { + _realInitState(); + } else { + _backgroundRefresh(); + } + } + } + + void _realInitState() { + ApplicationLog.debug('ChannelRootPage::_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; @@ -44,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 cd3ebeb..44d3328 100644 --- a/flutter/lib/pages/debug/debug_persistence.dart +++ b/flutter/lib/pages/debug/debug_persistence.dart @@ -1,10 +1,15 @@ 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'; +import 'package:simplecloudnotifier/state/fb_message.dart'; +import 'package:simplecloudnotifier/state/interfaces.dart'; import 'package:simplecloudnotifier/state/request_log.dart'; +import 'package:simplecloudnotifier/utils/navi.dart'; class DebugPersistencePage extends StatefulWidget { @override @@ -28,62 +33,56 @@ class _DebugPersistencePageState extends State { mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Card.outlined( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: GestureDetector( - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => DebugSharedPrefPage(sharedPref: prefs!))); - }, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - 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)), - ], - ), - ), - ), - ), - Card.outlined( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: GestureDetector( - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => DebugHiveBoxPage(boxName: 'scn-requests', box: Hive.box('scn-requests')))); - }, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - 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)), - ], - ), - ), - ), - ), - Card.outlined( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: GestureDetector( - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => DebugHiveBoxPage(boxName: 'scn-requests', box: Hive.box('scn-logs')))); - }, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - 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)), - ], - ), - ), - ), - ), + _buildSharedPrefCard(context), + _buildHiveCard(context, () => Hive.box('scn-requests'), 'scn-requests'), + _buildHiveCard(context, () => Hive.box('scn-logs'), 'scn-logs'), + _buildHiveCard(context, () => Hive.box('scn-message-cache'), 'scn-message-cache'), + _buildHiveCard(context, () => Hive.box('scn-channel-cache'), 'scn-channel-cache'), + _buildHiveCard(context, () => Hive.box('scn-fb-messages'), 'scn-fb-messages'), ], ), ); } + + Widget _buildSharedPrefCard(BuildContext context) { + return Card.outlined( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: GestureDetector( + onTap: () { + Navi.push(context, () => DebugSharedPrefPage(sharedPref: prefs!)); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SizedBox(width: 30, child: Text('')), + Expanded(child: Text('Shared Preferences', style: TextStyle(fontWeight: FontWeight.bold), textAlign: TextAlign.center)), + SizedBox(width: 40, child: Text('${prefs?.getKeys().length.toString()}', textAlign: TextAlign.end)), + ], + ), + ), + ), + ); + } + + Widget _buildHiveCard(BuildContext context, Box Function() boxFunc, String boxname) { + return Card.outlined( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: GestureDetector( + onTap: () { + Navi.push(context, () => DebugHiveBoxPage(boxName: boxname, box: Hive.box(boxname))); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SizedBox(width: 30, child: Text('')), + Expanded(child: Text('Hive [$boxname]', style: TextStyle(fontWeight: FontWeight.bold), textAlign: TextAlign.center)), + SizedBox(width: 40, child: Text('${boxFunc().length.toString()}', textAlign: TextAlign.end)), + ], + ), + ), + ), + ); + } } diff --git a/flutter/lib/pages/debug/debug_persistence_hive.dart b/flutter/lib/pages/debug/debug_persistence_hive.dart index c58a709..12aefd2 100644 --- a/flutter/lib/pages/debug/debug_persistence_hive.dart +++ b/flutter/lib/pages/debug/debug_persistence_hive.dart @@ -3,6 +3,7 @@ import 'package:hive_flutter/hive_flutter.dart'; import 'package:simplecloudnotifier/components/layout/scaffold.dart'; import 'package:simplecloudnotifier/pages/debug/debug_persistence_hiveentry.dart'; import 'package:simplecloudnotifier/state/interfaces.dart'; +import 'package:simplecloudnotifier/utils/navi.dart'; class DebugHiveBoxPage extends StatelessWidget { final String boxName; @@ -21,7 +22,7 @@ class DebugHiveBoxPage extends StatelessWidget { itemBuilder: (context, listIndex) { return GestureDetector( onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => DebugHiveEntryPage(value: box.getAt(listIndex)!))); + Navi.push(context, () => DebugHiveEntryPage(value: box.getAt(listIndex)!)); }, child: ListTile( title: Text(box.getAt(listIndex).toString(), style: TextStyle(fontWeight: FontWeight.bold)), diff --git a/flutter/lib/pages/debug/debug_requests.dart b/flutter/lib/pages/debug/debug_requests.dart index 27331e2..c0b2bc5 100644 --- a/flutter/lib/pages/debug/debug_requests.dart +++ b/flutter/lib/pages/debug/debug_requests.dart @@ -3,6 +3,7 @@ import 'package:hive_flutter/hive_flutter.dart'; import 'package:intl/intl.dart'; import 'package:simplecloudnotifier/pages/debug/debug_request_view.dart'; import 'package:simplecloudnotifier/state/request_log.dart'; +import 'package:simplecloudnotifier/utils/navi.dart'; class DebugRequestsPage extends StatefulWidget { @override @@ -40,7 +41,7 @@ class _DebugRequestsPageState extends State { return Padding( padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 2.0), child: GestureDetector( - onTap: () => Navigator.push(context, MaterialPageRoute(builder: (context) => DebugRequestViewPage(request: req))), + onTap: () => Navi.push(context, () => DebugRequestViewPage(request: req)), child: ListTile( tileColor: Theme.of(context).colorScheme.errorContainer, textColor: Theme.of(context).colorScheme.onErrorContainer, @@ -76,7 +77,7 @@ class _DebugRequestsPageState extends State { return Padding( padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 2.0), child: GestureDetector( - onTap: () => Navigator.push(context, MaterialPageRoute(builder: (context) => DebugRequestViewPage(request: req))), + onTap: () => Navi.push(context, () => DebugRequestViewPage(request: req)), child: ListTile( title: Row( children: [ diff --git a/flutter/lib/pages/message_list/message_list.dart b/flutter/lib/pages/message_list/message_list.dart index 3281e7e..8e91212 100644 --- a/flutter/lib/pages/message_list/message_list.dart +++ b/flutter/lib/pages/message_list/message_list.dart @@ -1,16 +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 @@ -19,31 +24,104 @@ 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'); + late final AppLifecycleListener _lifecyleListener; + + 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(); + + _lifecyleListener = AppLifecycleListener( + onResume: _onLifecycleResume, + ); + } + + @override + void didUpdateWidget(MessageListPage oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.isVisiblePage != widget.isVisiblePage && widget.isVisiblePage) { + if (!_isInitialized) { + _realInitState(); + } else { + _backgroundRefresh(false); + } + } + } + + void _realInitState() { + ApplicationLog.debug('MessageListPage::_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() { + ApplicationLog.debug('MessageListPage::dispose'); + Navi.modalRouteObserver.unsubscribe(this); _pagingController.dispose(); + _lifecyleListener.dispose(); super.dispose(); } + @override + void didPush() { + // ... + } + + @override + void didPopNext() { + ApplicationLog.debug('[MessageList::RouteObserver] --> didPopNext (will background-refresh)'); + _backgroundRefresh(false); + } + + void _onLifecycleResume() { + ApplicationLog.debug('[MessageList::_onLifecycleResume] --> (will background-refresh)'); + _backgroundRefresh(false); + } + 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; @@ -53,10 +131,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 { @@ -68,6 +152,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( @@ -83,7 +232,7 @@ class _MessageListPageState extends State { message: item, allChannels: _channels ?? {}, onPressed: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => MessageViewPage(message: item))); + Navi.push(context, () => MessageViewPage(message: item)); }, ), ), @@ -91,4 +240,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/message_list/message_list_item.dart b/flutter/lib/pages/message_list/message_list_item.dart index 852b1ec..44f01fd 100644 --- a/flutter/lib/pages/message_list/message_list_item.dart +++ b/flutter/lib/pages/message_list/message_list_item.dart @@ -48,10 +48,6 @@ class MessageListItem extends StatelessWidget { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (message.priority == 2) FaIcon(FontAwesomeIcons.solidTriangleExclamation, size: 16, color: Colors.red[900]), - if (message.priority == 2) SizedBox(width: 4), - if (message.priority == 0) FaIcon(FontAwesomeIcons.solidDown, size: 16, color: Colors.lightBlue[900]), - if (message.priority == 0) SizedBox(width: 4), Expanded( child: Text( processTitle(message.title), @@ -69,11 +65,22 @@ class MessageListItem extends StatelessWidget { ], ), SizedBox(height: 4), - Text( - processContent(message.content), - style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)), - overflow: TextOverflow.ellipsis, - maxLines: _lineCount, + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Text( + processContent(message.content), + style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)), + overflow: TextOverflow.ellipsis, + maxLines: _lineCount, + ), + ), + if (message.priority == 2) SizedBox(width: 4), + if (message.priority == 2) FaIcon(FontAwesomeIcons.solidTriangleExclamation, size: 16, color: Colors.red[900]), + if (message.priority == 0) SizedBox(width: 4), + if (message.priority == 0) FaIcon(FontAwesomeIcons.solidDown, size: 16, color: Colors.lightBlue[900]), + ], ), ], ), @@ -99,10 +106,6 @@ class MessageListItem extends StatelessWidget { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (message.priority == 2) FaIcon(FontAwesomeIcons.solidTriangleExclamation, size: 16, color: Colors.red[900]), - if (message.priority == 2) SizedBox(width: 4), - if (message.priority == 0) FaIcon(FontAwesomeIcons.solidDown, size: 16, color: Colors.lightBlue[900]), - if (message.priority == 0) SizedBox(width: 4), UI.channelChip( context: context, text: resolveChannelName(message), @@ -124,11 +127,22 @@ class MessageListItem extends StatelessWidget { overflow: TextOverflow.ellipsis, maxLines: 3, ), - Text( - processContent(message.content), - style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)), - overflow: TextOverflow.ellipsis, - maxLines: _lineCount, + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Text( + processContent(message.content), + style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)), + overflow: TextOverflow.ellipsis, + maxLines: _lineCount, + ), + ), + if (message.priority == 2) SizedBox(width: 4), + if (message.priority == 2) FaIcon(FontAwesomeIcons.solidTriangleExclamation, size: 16, color: Colors.red[900]), + if (message.priority == 0) SizedBox(width: 4), + if (message.priority == 0) FaIcon(FontAwesomeIcons.solidDown, size: 16, color: Colors.lightBlue[900]), + ], ), ], ), diff --git a/flutter/lib/pages/message_view/message_view.dart b/flutter/lib/pages/message_view/message_view.dart index 3d5c9cb..f266ba2 100644 --- a/flutter/lib/pages/message_view/message_view.dart +++ b/flutter/lib/pages/message_view/message_view.dart @@ -5,13 +5,13 @@ import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:simplecloudnotifier/api/api_client.dart'; -import 'package:simplecloudnotifier/api/api_exception.dart'; import 'package:simplecloudnotifier/components/layout/scaffold.dart'; -import 'package:simplecloudnotifier/models/api_error.dart'; import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/keytoken.dart'; import 'package:simplecloudnotifier/models/message.dart'; +import 'package:simplecloudnotifier/models/user.dart'; import 'package:simplecloudnotifier/state/app_auth.dart'; +import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/utils/toaster.dart'; import 'package:simplecloudnotifier/utils/ui.dart'; @@ -25,54 +25,46 @@ class MessageViewPage extends StatefulWidget { } class _MessageViewPageState extends State { - late Future<(Message, ChannelWithSubscription?, KeyToken?)>? mainFuture; - (Message, ChannelWithSubscription?, KeyToken?)? mainFutureSnapshot = null; + late Future<(Message, ChannelPreview, KeyTokenPreview, UserPreview)>? mainFuture; + (Message, ChannelPreview, KeyTokenPreview, UserPreview)? mainFutureSnapshot = null; static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm'); bool _monospaceMode = false; @override void initState() { - super.initState(); mainFuture = fetchData(); + super.initState(); } - Future<(Message, ChannelWithSubscription?, KeyToken?)> fetchData() async { - final acc = Provider.of(context, listen: false); - - final msg = await APIClient.getMessage(acc, widget.message.messageID); - - ChannelWithSubscription? chn = null; + Future<(Message, ChannelPreview, KeyTokenPreview, UserPreview)> fetchData() async { try { - chn = await APIClient.getChannel(acc, msg.channelID); //TODO getShortChannel (?) -> no perm - } on APIException catch (e) { - if (e.error == APIError.USER_AUTH_FAILED) { - chn = null; - } else { - rethrow; - } + 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 acc = Provider.of(context, listen: false); + + final msg = await APIClient.getMessage(acc, widget.message.messageID); + + final fut_chn = APIClient.getChannelPreview(acc, msg.channelID); + final fut_key = APIClient.getKeyTokenPreview(acc, msg.usedKeyID); + final fut_usr = APIClient.getUserPreview(acc, msg.senderUserID); + + final chn = await fut_chn; + final key = await fut_key; + final usr = await fut_usr; + + //await Future.delayed(const Duration(seconds: 10), () {}); + + final r = (msg, chn, key, usr); + + mainFutureSnapshot = r; + + return r; + } finally { + AppBarState().setLoadingIndeterminate(false); } - - KeyToken? tok = null; - try { - tok = await APIClient.getKeyToken(acc, msg.usedKeyID); //TODO getShortKeyToken (?) -> no perm - } on APIException catch (e) { - if (e.error == APIError.USER_AUTH_FAILED) { - tok = null; - } else { - rethrow; - } - } - - //TODO getShortUser for sender - - //await Future.delayed(const Duration(seconds: 2), () {}); - - final r = (msg, chn, tok); - - mainFutureSnapshot = r; - - return r; } @override @@ -87,16 +79,16 @@ class _MessageViewPageState extends State { showSearch: false, showShare: true, onShare: _share, - child: FutureBuilder<(Message, ChannelWithSubscription?, KeyToken?)>( + child: FutureBuilder<(Message, ChannelPreview, KeyTokenPreview, UserPreview)>( future: mainFuture, builder: (context, snapshot) { if (snapshot.hasData) { - final (msg, chn, tok) = snapshot.data!; - return _buildMessageView(context, msg, chn, tok, false); + final (msg, chn, tok, usr) = snapshot.data!; + return _buildMessageView(context, msg, chn, tok, usr); } else if (snapshot.hasError) { return Center(child: Text('${snapshot.error}')); //TODO nice error page } else if (!widget.message.trimmed) { - return _buildMessageView(context, widget.message, null, null, true); + return _buildMessageView(context, widget.message, null, null, null); } else { return const Center(child: CircularProgressIndicator()); } @@ -108,7 +100,7 @@ class _MessageViewPageState extends State { void _share() async { var msg = widget.message; if (mainFutureSnapshot != null) { - (msg, _, _) = mainFutureSnapshot!; + (msg, _, _, _) = mainFutureSnapshot!; } if (msg.content != null) { @@ -126,7 +118,7 @@ class _MessageViewPageState extends State { } } - Widget _buildMessageView(BuildContext context, Message message, ChannelWithSubscription? channel, KeyToken? token, bool loading) { + Widget _buildMessageView(BuildContext context, Message message, ChannelPreview? channel, KeyTokenPreview? token, UserPreview? user) { final userAccUserID = context.select((v) => v.userID); return SingleChildScrollView( @@ -135,16 +127,16 @@ class _MessageViewPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - ..._buildMessageHeader(context, message, channel, token, loading), + ..._buildMessageHeader(context, message, channel), SizedBox(height: 8), - if (message.content != null) ..._buildMessageContent(context, message, channel, token), + if (message.content != null) ..._buildMessageContent(context, message), SizedBox(height: 8), if (message.senderName != null) _buildMetaCard(context, FontAwesomeIcons.solidSignature, 'Sender', [message.senderName!], () => {/*TODO*/}), _buildMetaCard(context, FontAwesomeIcons.solidGearCode, 'KeyToken', [message.usedKeyID, if (token != null) token.name], () => {/*TODO*/}), _buildMetaCard(context, FontAwesomeIcons.solidIdCardClip, 'MessageID', [message.messageID, if (message.userMessageID != null) message.userMessageID!], null), - _buildMetaCard(context, FontAwesomeIcons.solidSnake, 'Channel', [message.channelID, channel?.channel.displayName ?? message.channelInternalName], () => {/*TODO*/}), + _buildMetaCard(context, FontAwesomeIcons.solidSnake, 'Channel', [message.channelID, channel?.displayName ?? message.channelInternalName], () => {/*TODO*/}), _buildMetaCard(context, FontAwesomeIcons.solidTimer, 'Timestamp', [message.timestamp], null), - _buildMetaCard(context, FontAwesomeIcons.solidUser, 'User', ['TODO'], () => {/*TODO*/}), //TODO + _buildMetaCard(context, FontAwesomeIcons.solidUser, 'User', [if (user != null) user.userID, if (user?.username != null) user!.username!], () => {/*TODO*/}), //TODO if (message.senderUserID == userAccUserID) UI.button(text: "Delete Message", onPressed: () {/*TODO*/}, color: Colors.red[900]), ], ), @@ -152,11 +144,11 @@ class _MessageViewPageState extends State { ); } - String _resolveChannelName(ChannelWithSubscription? channel, Message message) { - return channel?.channel.displayName ?? message.channelInternalName; + String _resolveChannelName(ChannelPreview? channel, Message message) { + return channel?.displayName ?? message.channelInternalName; } - List _buildMessageHeader(BuildContext context, Message message, ChannelWithSubscription? channel, KeyToken? token, bool loading) { + List _buildMessageHeader(BuildContext context, Message message, ChannelPreview? channel) { return [ Row( children: [ @@ -171,28 +163,11 @@ class _MessageViewPageState extends State { ], ), SizedBox(height: 8), - if (!loading) Text(message.title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - if (loading) - Stack( - children: [ - Row( - children: [ - Flexible(child: Text(message.title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold))), - SizedBox(height: 20, width: 20), - ], - ), - Row( - children: [ - Expanded(child: SizedBox(width: 0)), - SizedBox(child: CircularProgressIndicator(), height: 20, width: 20), - ], - ), - ], - ), + Text(_preformatTitle(message), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), ]; } - List _buildMessageContent(BuildContext context, Message message, ChannelWithSubscription? channel, KeyToken? token) { + List _buildMessageContent(BuildContext context, Message message) { return [ Row( children: [ @@ -273,4 +248,8 @@ class _MessageViewPageState extends State { ); } } + + String _preformatTitle(Message message) { + return message.title.replaceAll('\n', '').replaceAll('\r', '').replaceAll('\t', ' '); + } } diff --git a/flutter/lib/pages/send/send.dart b/flutter/lib/pages/send/send.dart index 573c327..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(); @@ -130,6 +130,8 @@ class _SendRootPageState extends State { try { final Uri uri = Uri.parse(url); + ApplicationLog.debug('Opening URL: [ ${uri.toString()} ]'); + if (await canLaunchUrl(uri)) { await launchUrl(uri); } else { 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..a161485 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; @@ -117,14 +118,17 @@ class AppAuth extends ChangeNotifier implements TokenSource { final user = await APIClient.getUser(this, _userID!); _user = user; - notifyListeners(); await save(); 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!; } @@ -137,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/app_bar_state.dart b/flutter/lib/state/app_bar_state.dart new file mode 100644 index 0000000..b62384a --- /dev/null +++ b/flutter/lib/state/app_bar_state.dart @@ -0,0 +1,20 @@ +import 'package:flutter/foundation.dart'; + +class AppBarState extends ChangeNotifier { + static AppBarState? _singleton = AppBarState._internal(); + + factory AppBarState() { + return _singleton ?? (_singleton = AppBarState._internal()); + } + + AppBarState._internal() {} + + bool _loadingIndeterminate = false; + bool get loadingIndeterminate => _loadingIndeterminate; + + void setLoadingIndeterminate(bool v) { + if (_loadingIndeterminate == v) return; + _loadingIndeterminate = v; + notifyListeners(); + } +} diff --git a/flutter/lib/state/application_log.dart b/flutter/lib/state/application_log.dart index 071195c..d997a7a 100644 --- a/flutter/lib/state/application_log.dart +++ b/flutter/lib/state/application_log.dart @@ -5,8 +5,10 @@ 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}) { - print('[DEBUG] ${message}: ${additional ?? ''}'); + (additional != null && additional != '') ? print('[DEBUG] ${message}: ${additional}') : print('[DEBUG] ${message}'); Hive.box('scn-logs').add(SCNLog( id: Xid().toString(), @@ -19,7 +21,7 @@ class ApplicationLog { } static void info(String message, {String? additional, StackTrace? trace}) { - print('[INFO] ${message}: ${additional ?? ''}'); + (additional != null && additional != '') ? print('[INFO] ${message}: ${additional}') : print('[INFO] ${message}'); Hive.box('scn-logs').add(SCNLog( id: Xid().toString(), @@ -32,7 +34,7 @@ class ApplicationLog { } static void warn(String message, {String? additional, StackTrace? trace}) { - print('[WARN] ${message}: ${additional ?? ''}'); + (additional != null && additional != '') ? print('[WARN] ${message}: ${additional}') : print('[WARN] ${message}'); Hive.box('scn-logs').add(SCNLog( id: Xid().toString(), @@ -45,7 +47,7 @@ class ApplicationLog { } static void error(String message, {String? additional, StackTrace? trace}) { - print('[ERROR] ${message}: ${additional ?? ''}'); + (additional != null && additional != '') ? print('[ERROR] ${message}: ${additional}') : print('[ERROR] ${message}'); Hive.box('scn-logs').add(SCNLog( id: Xid().toString(), @@ -58,7 +60,7 @@ class ApplicationLog { } static void fatal(String message, {String? additional, StackTrace? trace}) { - print('[FATAL] ${message}: ${additional ?? ''}'); + (additional != null && additional != '') ? print('[FATAL] ${message}: ${additional}') : print('[FATAL] ${message}'); Hive.box('scn-logs').add(SCNLog( id: Xid().toString(), diff --git a/flutter/lib/state/fb_message.dart b/flutter/lib/state/fb_message.dart new file mode 100644 index 0000000..b57e372 --- /dev/null +++ b/flutter/lib/state/fb_message.dart @@ -0,0 +1,236 @@ +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:simplecloudnotifier/state/interfaces.dart'; + +part 'fb_message.g.dart'; + +class FBMessageLog { + //TODO max size, auto clear old + + static void insert(RemoteMessage msg) { + Hive.box('scn-fb-messages').add(FBMessage.fromRemoteMessage(msg)); + } +} + +@HiveType(typeId: 106) +class FBMessage extends HiveObject implements FieldDebuggable { + @HiveField(0) + final String? senderId; + @HiveField(1) + final String? category; + @HiveField(2) + final String? collapseKey; + @HiveField(3) + final bool contentAvailable; + @HiveField(4) + final Map data; + @HiveField(5) + final String? from; + @HiveField(6) + final String? messageId; + @HiveField(7) + final String? messageType; + @HiveField(8) + final bool mutableContent; + @HiveField(9) + final RemoteNotification? notification; + @HiveField(10) + final DateTime? sentTime; + @HiveField(11) + final String? threadId; + @HiveField(12) + final int? ttl; + + @HiveField(20) + final String? notificationAndroidChannelId; + @HiveField(21) + final String? notificationAndroidClickAction; + @HiveField(22) + final String? notificationAndroidColor; + @HiveField(23) + final int? notificationAndroidCount; + @HiveField(24) + final String? notificationAndroidImageUrl; + @HiveField(25) + final String? notificationAndroidLink; + @HiveField(26) + final AndroidNotificationPriority? notificationAndroidPriority; + @HiveField(27) + final String? notificationAndroidSmallIcon; + @HiveField(28) + final String? notificationAndroidSound; + @HiveField(29) + final String? notificationAndroidTicker; + @HiveField(30) + final AndroidNotificationVisibility? notificationAndroidVisibility; + @HiveField(31) + final String? notificationAndroidTag; + + @HiveField(40) + final String? notificationAppleBadge; + @HiveField(41) + final AppleNotificationSound? notificationAppleSound; + @HiveField(42) + final String? notificationAppleImageUrl; + @HiveField(43) + final String? notificationAppleSubtitle; + @HiveField(44) + final List? notificationAppleSubtitleLocArgs; + @HiveField(45) + final String? notificationAppleSubtitleLocKey; + + @HiveField(50) + final String? notificationWebAnalyticsLabel; + @HiveField(51) + final String? notificationWebImage; + @HiveField(52) + final String? notificationWebLink; + + @HiveField(60) + final String? notificationTitle; + @HiveField(61) + final List? notificationTitleLocArgs; + @HiveField(62) + final String? notificationTitleLocKey; + @HiveField(63) + final String? notificationBody; + @HiveField(64) + final List? notificationBodyLocArgs; + @HiveField(65) + final String? notificationBodyLocKey; + + FBMessage({ + required this.senderId, + required this.category, + required this.collapseKey, + required this.contentAvailable, + required this.data, + required this.from, + required this.messageId, + required this.messageType, + required this.mutableContent, + required this.notification, + required this.sentTime, + required this.threadId, + required this.ttl, + required this.notificationAndroidChannelId, + required this.notificationAndroidClickAction, + required this.notificationAndroidColor, + required this.notificationAndroidCount, + required this.notificationAndroidImageUrl, + required this.notificationAndroidLink, + required this.notificationAndroidPriority, + required this.notificationAndroidSmallIcon, + required this.notificationAndroidSound, + required this.notificationAndroidTicker, + required this.notificationAndroidVisibility, + required this.notificationAndroidTag, + required this.notificationAppleBadge, + required this.notificationAppleSound, + required this.notificationAppleImageUrl, + required this.notificationAppleSubtitle, + required this.notificationAppleSubtitleLocArgs, + required this.notificationAppleSubtitleLocKey, + required this.notificationWebAnalyticsLabel, + required this.notificationWebImage, + required this.notificationWebLink, + required this.notificationTitle, + required this.notificationTitleLocArgs, + required this.notificationTitleLocKey, + required this.notificationBody, + required this.notificationBodyLocArgs, + required this.notificationBodyLocKey, + }); + + FBMessage.fromRemoteMessage(RemoteMessage rmsg) + : this.senderId = rmsg.senderId, + this.category = rmsg.category, + this.collapseKey = rmsg.collapseKey, + this.contentAvailable = rmsg.contentAvailable, + this.data = rmsg.data.map((key, value) => MapEntry(key, value?.toString() ?? '')), + this.from = rmsg.from, + this.messageId = rmsg.messageId, + this.messageType = rmsg.messageType, + this.mutableContent = rmsg.mutableContent, + this.notification = rmsg.notification, + this.sentTime = rmsg.sentTime, + this.threadId = rmsg.threadId, + this.ttl = rmsg.ttl, + this.notificationAndroidChannelId = rmsg.notification?.android?.channelId, + this.notificationAndroidClickAction = rmsg.notification?.android?.clickAction, + this.notificationAndroidColor = rmsg.notification?.android?.color, + this.notificationAndroidCount = rmsg.notification?.android?.count, + this.notificationAndroidImageUrl = rmsg.notification?.android?.imageUrl, + this.notificationAndroidLink = rmsg.notification?.android?.link, + this.notificationAndroidPriority = rmsg.notification?.android?.priority, + this.notificationAndroidSmallIcon = rmsg.notification?.android?.smallIcon, + this.notificationAndroidSound = rmsg.notification?.android?.sound, + this.notificationAndroidTicker = rmsg.notification?.android?.ticker, + this.notificationAndroidVisibility = rmsg.notification?.android?.visibility, + this.notificationAndroidTag = rmsg.notification?.android?.tag, + this.notificationAppleBadge = rmsg.notification?.apple?.badge, + this.notificationAppleSound = rmsg.notification?.apple?.sound, + this.notificationAppleImageUrl = rmsg.notification?.apple?.imageUrl, + this.notificationAppleSubtitle = rmsg.notification?.apple?.subtitle, + this.notificationAppleSubtitleLocArgs = rmsg.notification?.apple?.subtitleLocArgs, + this.notificationAppleSubtitleLocKey = rmsg.notification?.apple?.subtitleLocKey, + this.notificationWebAnalyticsLabel = rmsg.notification?.web?.analyticsLabel, + this.notificationWebImage = rmsg.notification?.web?.image, + this.notificationWebLink = rmsg.notification?.web?.link, + this.notificationTitle = rmsg.notification?.title, + this.notificationTitleLocArgs = rmsg.notification?.titleLocArgs, + this.notificationTitleLocKey = rmsg.notification?.titleLocKey, + this.notificationBody = rmsg.notification?.body, + this.notificationBodyLocArgs = rmsg.notification?.bodyLocArgs, + this.notificationBodyLocKey = rmsg.notification?.bodyLocKey {} + + @override + String toString() { + return 'FBMessage[${this.messageId ?? 'NULL'}]'; + } + + List<(String, String)> debugFieldList() { + return [ + ('senderId', this.senderId ?? ''), + ('category', this.category ?? ''), + ('collapseKey', this.collapseKey ?? ''), + ('contentAvailable', this.contentAvailable.toString()), + ('data', this.data.toString()), + ('from', this.from ?? ''), + ('messageId', this.messageId ?? ''), + ('messageType', this.messageType ?? ''), + ('mutableContent', this.mutableContent.toString()), + ('notification', this.notification?.toString() ?? ''), + ('sentTime', this.sentTime?.toString() ?? ''), + ('threadId', this.threadId ?? ''), + ('ttl', this.ttl?.toString() ?? ''), + ('notification.Android.ChannelId', this.notificationAndroidChannelId ?? ''), + ('notification.Android.ClickAction', this.notificationAndroidClickAction ?? ''), + ('notification.Android.Color', this.notificationAndroidColor ?? ''), + ('notification.Android.Count', this.notificationAndroidCount?.toString() ?? ''), + ('notification.Android.ImageUrl', this.notificationAndroidImageUrl ?? ''), + ('notification.Android.Link', this.notificationAndroidLink ?? ''), + ('notification.Android.Priority', this.notificationAndroidPriority?.toString() ?? ''), + ('notification.Android.SmallIcon', this.notificationAndroidSmallIcon ?? ''), + ('notification.Android.Sound', this.notificationAndroidSound ?? ''), + ('notification.Android.Ticker', this.notificationAndroidTicker ?? ''), + ('notification.Android.Visibility', this.notificationAndroidVisibility?.toString() ?? ''), + ('notification.Android.Tag', this.notificationAndroidTag ?? ''), + ('notification.Apple.Badge', this.notificationAppleBadge ?? ''), + ('notification.Apple.Sound', this.notificationAppleSound?.toString() ?? ''), + ('notification.Apple.ImageUrl', this.notificationAppleImageUrl ?? ''), + ('notification.Apple.Subtitle', this.notificationAppleSubtitle ?? ''), + ('notification.Apple.SubtitleLocArgs', this.notificationAppleSubtitleLocArgs?.toString() ?? ''), + ('notification.Apple.SubtitleLocKey', this.notificationAppleSubtitleLocKey ?? ''), + ('notification.Web.AnalyticsLabel', this.notificationWebAnalyticsLabel ?? ''), + ('notification.Web.Image', this.notificationWebImage ?? ''), + ('notification.Web.Link', this.notificationWebLink ?? ''), + ('notification.Title', this.notificationTitle ?? ''), + ('notification.TitleLocArgs', this.notificationTitleLocArgs?.toString() ?? ''), + ('notification.TitleLocKey', this.notificationTitleLocKey ?? ''), + ('notification.Body', this.notificationBody ?? ''), + ('notification.BodyLocArgs', this.notificationBodyLocArgs?.toString() ?? ''), + ('notification.BodyLocKey', this.notificationBodyLocKey ?? ''), + ]; + } +} diff --git a/flutter/lib/state/fb_message.g.dart b/flutter/lib/state/fb_message.g.dart new file mode 100644 index 0000000..e10f640 --- /dev/null +++ b/flutter/lib/state/fb_message.g.dart @@ -0,0 +1,159 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'fb_message.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class FBMessageAdapter extends TypeAdapter { + @override + final int typeId = 106; + + @override + FBMessage read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return FBMessage( + senderId: fields[0] as String?, + category: fields[1] as String?, + collapseKey: fields[2] as String?, + contentAvailable: fields[3] as bool, + data: (fields[4] as Map).cast(), + from: fields[5] as String?, + messageId: fields[6] as String?, + messageType: fields[7] as String?, + mutableContent: fields[8] as bool, + notification: fields[9] as RemoteNotification?, + sentTime: fields[10] as DateTime?, + threadId: fields[11] as String?, + ttl: fields[12] as int?, + notificationAndroidChannelId: fields[20] as String?, + notificationAndroidClickAction: fields[21] as String?, + notificationAndroidColor: fields[22] as String?, + notificationAndroidCount: fields[23] as int?, + notificationAndroidImageUrl: fields[24] as String?, + notificationAndroidLink: fields[25] as String?, + notificationAndroidPriority: fields[26] as AndroidNotificationPriority?, + notificationAndroidSmallIcon: fields[27] as String?, + notificationAndroidSound: fields[28] as String?, + notificationAndroidTicker: fields[29] as String?, + notificationAndroidVisibility: + fields[30] as AndroidNotificationVisibility?, + notificationAndroidTag: fields[31] as String?, + notificationAppleBadge: fields[40] as String?, + notificationAppleSound: fields[41] as AppleNotificationSound?, + notificationAppleImageUrl: fields[42] as String?, + notificationAppleSubtitle: fields[43] as String?, + notificationAppleSubtitleLocArgs: (fields[44] as List?)?.cast(), + notificationAppleSubtitleLocKey: fields[45] as String?, + notificationWebAnalyticsLabel: fields[50] as String?, + notificationWebImage: fields[51] as String?, + notificationWebLink: fields[52] as String?, + notificationTitle: fields[60] as String?, + notificationTitleLocArgs: (fields[61] as List?)?.cast(), + notificationTitleLocKey: fields[62] as String?, + notificationBody: fields[63] as String?, + notificationBodyLocArgs: (fields[64] as List?)?.cast(), + notificationBodyLocKey: fields[65] as String?, + ); + } + + @override + void write(BinaryWriter writer, FBMessage obj) { + writer + ..writeByte(40) + ..writeByte(0) + ..write(obj.senderId) + ..writeByte(1) + ..write(obj.category) + ..writeByte(2) + ..write(obj.collapseKey) + ..writeByte(3) + ..write(obj.contentAvailable) + ..writeByte(4) + ..write(obj.data) + ..writeByte(5) + ..write(obj.from) + ..writeByte(6) + ..write(obj.messageId) + ..writeByte(7) + ..write(obj.messageType) + ..writeByte(8) + ..write(obj.mutableContent) + ..writeByte(9) + ..write(obj.notification) + ..writeByte(10) + ..write(obj.sentTime) + ..writeByte(11) + ..write(obj.threadId) + ..writeByte(12) + ..write(obj.ttl) + ..writeByte(20) + ..write(obj.notificationAndroidChannelId) + ..writeByte(21) + ..write(obj.notificationAndroidClickAction) + ..writeByte(22) + ..write(obj.notificationAndroidColor) + ..writeByte(23) + ..write(obj.notificationAndroidCount) + ..writeByte(24) + ..write(obj.notificationAndroidImageUrl) + ..writeByte(25) + ..write(obj.notificationAndroidLink) + ..writeByte(26) + ..write(obj.notificationAndroidPriority) + ..writeByte(27) + ..write(obj.notificationAndroidSmallIcon) + ..writeByte(28) + ..write(obj.notificationAndroidSound) + ..writeByte(29) + ..write(obj.notificationAndroidTicker) + ..writeByte(30) + ..write(obj.notificationAndroidVisibility) + ..writeByte(31) + ..write(obj.notificationAndroidTag) + ..writeByte(40) + ..write(obj.notificationAppleBadge) + ..writeByte(41) + ..write(obj.notificationAppleSound) + ..writeByte(42) + ..write(obj.notificationAppleImageUrl) + ..writeByte(43) + ..write(obj.notificationAppleSubtitle) + ..writeByte(44) + ..write(obj.notificationAppleSubtitleLocArgs) + ..writeByte(45) + ..write(obj.notificationAppleSubtitleLocKey) + ..writeByte(50) + ..write(obj.notificationWebAnalyticsLabel) + ..writeByte(51) + ..write(obj.notificationWebImage) + ..writeByte(52) + ..write(obj.notificationWebLink) + ..writeByte(60) + ..write(obj.notificationTitle) + ..writeByte(61) + ..write(obj.notificationTitleLocArgs) + ..writeByte(62) + ..write(obj.notificationTitleLocKey) + ..writeByte(63) + ..write(obj.notificationBody) + ..writeByte(64) + ..write(obj.notificationBodyLocArgs) + ..writeByte(65) + ..write(obj.notificationBodyLocKey); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FBMessageAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} 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; +} diff --git a/flutter/lib/utils/navi.dart b/flutter/lib/utils/navi.dart index 6b6d16c..61d9d93 100644 --- a/flutter/lib/utils/navi.dart +++ b/flutter/lib/utils/navi.dart @@ -1 +1,52 @@ -class Navi {} +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +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); + + Navigator.push(context, MaterialPageRoute(builder: (context) => builder())); + } + + static void popToRoot(BuildContext context) { + Provider.of(context, listen: false).setLoadingIndeterminate(false); + + Navigator.popUntil(context, (route) => route.isFirst); + } +} + +class SCNRouteObserver extends RouteObserver> { + @override + void didPush(Route route, Route? previousRoute) { + super.didPush(route, previousRoute); + if (route is PageRoute) { + AppBarState().setLoadingIndeterminate(false); + + print('[SCNRouteObserver] .didPush()'); + } + } + + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + super.didReplace(newRoute: newRoute, oldRoute: oldRoute); + if (newRoute is PageRoute) { + AppBarState().setLoadingIndeterminate(false); + + print('[SCNRouteObserver] .didReplace()'); + } + } + + @override + void didPop(Route route, Route? previousRoute) { + super.didPop(route, previousRoute); + if (previousRoute is PageRoute && route is PageRoute) { + AppBarState().setLoadingIndeterminate(false); + + print('[SCNRouteObserver] .didPop()'); + } + } +} diff --git a/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index a2ec33f..96d3fee 100644 --- a/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,68 +1,68 @@ { - "images" : [ - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_16.png", - "scale" : "1x" + "info": { + "version": 1, + "author": "xcode" }, - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "2x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "1x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_64.png", - "scale" : "2x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_128.png", - "scale" : "1x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "2x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "1x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "2x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "1x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_1024.png", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} + "images": [ + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_16.png", + "scale": "1x" + }, + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "2x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "1x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_64.png", + "scale": "2x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_128.png", + "scale": "1x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "2x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "1x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "2x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "1x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_1024.png", + "scale": "2x" + } + ] +} \ No newline at end of file diff --git a/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png index 82b6f9d..83a1d62 100644 Binary files a/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and b/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png index 13b35eb..e4fbd65 100644 Binary files a/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and b/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png index 0a3f5fa..40006f7 100644 Binary files a/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and b/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png index bdb5722..d5f19ad 100644 Binary files a/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and b/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png index f083318..fcfbc47 100644 Binary files a/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and b/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png index 326c0e7..f74fb0c 100644 Binary files a/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and b/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png index 2f1632c..fe68c86 100644 Binary files a/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and b/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 4c21d1b..0e0c132 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -25,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.4.1" + archive: + dependency: transitive + description: + name: archive + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d + url: "https://pub.dev" + source: hosted + version: "3.6.1" args: dependency: transitive description: @@ -129,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19 + url: "https://pub.dev" + source: hosted + version: "0.4.1" clock: dependency: transitive description: @@ -253,18 +269,18 @@ packages: dependency: transitive description: name: firebase_core_platform_interface - sha256: c437ae5d17e6b5cc7981cf6fd458a5db4d12979905f9aafd1fea930428a9fe63 + sha256: "1003a5a03a61fc9a22ef49f37cbcb9e46c86313a7b2e7029b9390cf8c6fc32cb" url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "5.1.0" firebase_core_web: dependency: transitive description: name: firebase_core_web - sha256: "43d9e951ac52b87ae9cc38ecdcca1e8fa7b52a1dd26a96085ba41ce5108db8e9" + sha256: "6643fe3dbd021e6ccfb751f7882b39df355708afbdeb4130fc50f9305a9d1a3d" url: "https://pub.dev" source: hosted - version: "2.17.0" + version: "2.17.2" firebase_messaging: dependency: "direct main" description: @@ -302,6 +318,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_launcher_icons: + dependency: "direct main" + description: + name: flutter_launcher_icons + sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + url: "https://pub.dev" + source: hosted + version: "0.13.1" flutter_lazy_indexed_stack: dependency: "direct main" description: @@ -423,6 +447,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + image: + dependency: transitive + description: + name: image + sha256: "2237616a36c0d69aef7549ab439b833fb7f9fb9fc861af2cc9ac3eedddd69ca8" + url: "https://pub.dev" + source: hosted + version: "4.2.0" infinite_scroll_pagination: dependency: "direct main" description: @@ -631,14 +663,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0+3" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.dev" + source: hosted + version: "6.0.2" platform: dependency: transitive description: name: platform - sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" plugin_platform_interface: dependency: transitive description: @@ -675,10 +715,10 @@ packages: dependency: transitive description: name: pubspec_parse - sha256: c63b2876e58e194e4b0828fcb080ad0e06d051cb607a6be51a9e084f47cb9367 + sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8 url: "https://pub.dev" source: hosted - version: "1.2.3" + version: "1.3.0" qr: dependency: transitive description: @@ -904,10 +944,10 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: "6ce1e04375be4eed30548f10a315826fd933c1e493206eab82eed01f438c8d2e" + sha256: "21b704ce5fa560ea9f3b525b43601c678728ba46725bab9b01187b4831377ed3" url: "https://pub.dev" source: hosted - version: "6.2.6" + version: "6.3.0" url_launcher_android: dependency: transitive description: @@ -1044,6 +1084,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" yaml: dependency: transitive description: diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index a47d181..e3df6d1 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -1,5 +1,5 @@ name: simplecloudnotifier -description: "A new Flutter project." +description: "Receive push messages" publish_to: 'none' version: 2.0.0+100 @@ -11,6 +11,8 @@ dependencies: flutter: sdk: flutter + flutter_launcher_icons: "^0.13.1" + font_awesome_flutter: '>= 4.7.0' cupertino_icons: ^1.0.2 http: ^1.2.0 diff --git a/flutter/web/favicon.png b/flutter/web/favicon.png index 8aaa46a..40006f7 100644 Binary files a/flutter/web/favicon.png and b/flutter/web/favicon.png differ diff --git a/flutter/web/icons/Icon-192.png b/flutter/web/icons/Icon-192.png index b749bfe..814b148 100644 Binary files a/flutter/web/icons/Icon-192.png and b/flutter/web/icons/Icon-192.png differ diff --git a/flutter/web/icons/Icon-512.png b/flutter/web/icons/Icon-512.png index 88cfd48..f74fb0c 100644 Binary files a/flutter/web/icons/Icon-512.png and b/flutter/web/icons/Icon-512.png differ diff --git a/flutter/web/icons/Icon-maskable-192.png b/flutter/web/icons/Icon-maskable-192.png index eb9b4d7..814b148 100644 Binary files a/flutter/web/icons/Icon-maskable-192.png and b/flutter/web/icons/Icon-maskable-192.png differ diff --git a/flutter/web/icons/Icon-maskable-512.png b/flutter/web/icons/Icon-maskable-512.png index d69c566..f74fb0c 100644 Binary files a/flutter/web/icons/Icon-maskable-512.png and b/flutter/web/icons/Icon-maskable-512.png differ diff --git a/flutter/web/manifest.json b/flutter/web/manifest.json index b609ef1..31cd8b2 100644 --- a/flutter/web/manifest.json +++ b/flutter/web/manifest.json @@ -3,8 +3,8 @@ "short_name": "simplecloudnotifier", "start_url": ".", "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", + "background_color": "#hexcode", + "theme_color": "#hexcode", "description": "A new Flutter project.", "orientation": "portrait-primary", "prefer_related_applications": false, @@ -32,4 +32,4 @@ "purpose": "maskable" } ] -} +} \ No newline at end of file diff --git a/flutter/windows/runner/resources/app_icon.ico b/flutter/windows/runner/resources/app_icon.ico index c04e20c..3aa718f 100644 Binary files a/flutter/windows/runner/resources/app_icon.ico and b/flutter/windows/runner/resources/app_icon.ico differ diff --git a/scnserver/push/firebase.go b/scnserver/push/firebase.go index 90030ae..75c3644 100644 --- a/scnserver/push/firebase.go +++ b/scnserver/push/firebase.go @@ -11,8 +11,10 @@ import ( "fmt" "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" + "gogs.mikescher.com/BlackForestBytes/goext/langext" "io" "net/http" + "strconv" "strings" "time" ) @@ -55,30 +57,43 @@ func (fb FirebaseConnector) SendNotification(ctx context.Context, user models.Us uri := "https://fcm.googleapis.com/v1/projects/" + fb.fbProject + "/messages:send" - jsonBody := gin.H{ - "token": client.FCMToken, - } + jsonBody := gin.H{} if client.Type == models.ClientTypeIOS { - jsonBody["notification"] = gin.H{ - "title": msg.Title, - "body": msg.ShortContent(), - } - jsonBody["apns"] = gin.H{} - } else if client.Type == models.ClientTypeAndroid { - jsonBody["android"] = gin.H{ - "priority": "high", + jsonBody = gin.H{ + "token": client.FCMToken, "notification": gin.H{ - "event_time": msg.Timestamp().Format(time.RFC3339), - "title": msg.FormatNotificationTitle(user, channel), - "body": msg.ShortContent(), + "title": msg.Title, + "body": msg.ShortContent(), + }, + "apns": gin.H{}, + } + } else if client.Type == models.ClientTypeAndroid { + jsonBody = gin.H{ + "token": client.FCMToken, + "android": gin.H{ + "priority": "high", + "fcm_options": gin.H{}, + }, + "data": gin.H{ + "scn_msg_id": msg.MessageID.String(), + "usr_msg_id": langext.Coalesce(msg.UserMessageID, ""), + "client_id": client.ClientID.String(), + "timestamp": strconv.FormatInt(msg.Timestamp().Unix(), 10), + "priority": strconv.Itoa(msg.Priority), + "trimmed": langext.Conditional(msg.NeedsTrim(), "true", "false"), + "title": msg.Title, + "channel": channel.DisplayName, + "body": langext.Coalesce(msg.TrimmedContent(), ""), }, - "fcm_options": gin.H{}, } } else { - jsonBody["notification"] = gin.H{ - "title": msg.FormatNotificationTitle(user, channel), - "body": msg.ShortContent(), + jsonBody = gin.H{ + "token": client.FCMToken, + "notification": gin.H{ + "title": msg.FormatNotificationTitle(user, channel), + "body": msg.ShortContent(), + }, } }