From e9ea573e33378f8011810c302e07f3ad5e1f9856 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Sat, 15 Jun 2024 21:29:51 +0200 Subject: [PATCH 01/16] Notifications (android via local) work --- flutter/android/app/build.gradle | 9 +- flutter/android/app/proguard-rules.pro | 27 +++ .../android/app/src/main/AndroidManifest.xml | 3 + .../drawable-hdpi/ic_notification_white.png | Bin 0 -> 472 bytes .../drawable-mdpi/ic_notification_white.png | Bin 0 -> 320 bytes .../drawable-v24/ic_launcher_foreground.xml | 34 ++++ .../drawable-xhdpi/ic_notification_white.png | Bin 0 -> 551 bytes .../drawable-xxhdpi/ic_notification_white.png | Bin 0 -> 949 bytes .../ic_notification_white.png | Bin 0 -> 1067 bytes flutter/android/app/src/main/res/raw/keep.xml | 2 + flutter/android/build.gradle | 1 + flutter/ios/Runner/AppDelegate.swift | 14 ++ flutter/lib/api/api_client.dart | 10 +- flutter/lib/components/layout/app_bar.dart | 8 +- flutter/lib/components/layout/scaffold.dart | 3 - flutter/lib/main.dart | 162 ++++++++++++++++-- .../models/{message.dart => scn_message.dart} | 14 +- .../{message.g.dart => scn_message.g.dart} | 12 +- flutter/lib/nav_layout.dart | 1 - .../pages/channel_list/channel_list_item.dart | 4 +- flutter/lib/pages/debug/debug_actions.dart | 7 + flutter/lib/pages/debug/debug_main.dart | 1 - .../lib/pages/debug/debug_persistence.dart | 6 +- .../pages/debug/debug_persistence_hive.dart | 6 +- .../debug/debug_persistence_hiveentry.dart | 4 +- .../debug/debug_persistence_sharedprefs.dart | 1 - .../lib/pages/debug/debug_request_view.dart | 1 - .../lib/pages/message_list/message_list.dart | 29 ++-- .../pages/message_list/message_list_item.dart | 8 +- .../lib/pages/message_view/message_view.dart | 22 +-- flutter/lib/settings/app_settings.dart | 33 ++++ flutter/lib/state/application_log.dart | 5 + flutter/lib/state/fb_message.dart | 19 +- flutter/lib/state/fb_message.g.dart | 12 +- flutter/lib/state/globals.dart | 9 + flutter/lib/utils/notifier.dart | 70 ++++++++ .../Flutter/GeneratedPluginRegistrant.swift | 2 + flutter/pubspec.lock | 40 +++++ flutter/pubspec.yaml | 1 + 39 files changed, 476 insertions(+), 104 deletions(-) create mode 100644 flutter/android/app/proguard-rules.pro create mode 100644 flutter/android/app/src/main/res/drawable-hdpi/ic_notification_white.png create mode 100644 flutter/android/app/src/main/res/drawable-mdpi/ic_notification_white.png create mode 100644 flutter/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 flutter/android/app/src/main/res/drawable-xhdpi/ic_notification_white.png create mode 100644 flutter/android/app/src/main/res/drawable-xxhdpi/ic_notification_white.png create mode 100644 flutter/android/app/src/main/res/drawable-xxxhdpi/ic_notification_white.png create mode 100644 flutter/android/app/src/main/res/raw/keep.xml rename flutter/lib/models/{message.dart => scn_message.dart} (84%) rename flutter/lib/models/{message.g.dart => scn_message.g.dart} (88%) create mode 100644 flutter/lib/settings/app_settings.dart create mode 100644 flutter/lib/utils/notifier.dart diff --git a/flutter/android/app/build.gradle b/flutter/android/app/build.gradle index 10a233e..2130f2f 100644 --- a/flutter/android/app/build.gradle +++ b/flutter/android/app/build.gradle @@ -37,6 +37,7 @@ android { ndkVersion flutter.ndkVersion compileOptions { + coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } @@ -55,6 +56,7 @@ android { targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName + multiDexEnabled true } signingConfigs { @@ -77,4 +79,9 @@ flutter { source '../..' } -dependencies {} +dependencies { + implementation 'androidx.window:window:1.0.0' + implementation 'androidx.window:window-java:1.0.0' + + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.2.2' +} diff --git a/flutter/android/app/proguard-rules.pro b/flutter/android/app/proguard-rules.pro new file mode 100644 index 0000000..f8768e4 --- /dev/null +++ b/flutter/android/app/proguard-rules.pro @@ -0,0 +1,27 @@ +## Gson rules +# Gson uses generic type information stored in a class file when working with fields. Proguard +# removes such information by default, so configure it to keep all of it. +-keepattributes Signature + +# For using GSON @Expose annotation +-keepattributes *Annotation* + +# Gson specific classes +-dontwarn sun.misc.** +#-keep class com.google.gson.stream.** { *; } + +# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory, +# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter) +-keep class * extends com.google.gson.TypeAdapter +-keep class * implements com.google.gson.TypeAdapterFactory +-keep class * implements com.google.gson.JsonSerializer +-keep class * implements com.google.gson.JsonDeserializer + +# Prevent R8 from leaving Data object members always null +-keepclassmembers,allowobfuscation class * { + @com.google.gson.annotations.SerializedName ; +} + +# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher. +-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken +-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken \ 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 629c8ae..29a0228 100644 --- a/flutter/android/app/src/main/AndroidManifest.xml +++ b/flutter/android/app/src/main/AndroidManifest.xml @@ -31,6 +31,9 @@ + + + 6P)Nklg+M-Q4 zI4WX|?=Qb6L(lW<^Rmy3!+h)0KEG{u=JU+V&NCZ}nV6WE_=^yt5p;ka&Bobf}T=1IM3MsS>7K6&PfpcECSzfS7zP9M~S=0AV zK|_$~A(=ht?y9^{!;Da#+t~!;p0@;ia2MQxVnjk#XiFIMvd0KTLaX2&jJgRefq5_m z($f6bYYkbfj>Eirf#S;3QPiBvf(|y3`~@@5k+gHb74C8lvlCC!q;V zBXsLDWzFD8gV3_uPk#uM6$-u3qpH>hNppP$=C~nRS1AwYAGbQi?O62yxTce*pTk(0 z@9Lc!b2}l{s&m|qRX;+qag00w O0000scEnV5YGeRPe6PRioXHz5+F9A)bJ=E-Uq~aK)f7?yPxxy%rRw=y7nH%04hckB2ZIUO^=b&Vn3` zEj8r;@eT@uXdw`5;>!>EKs*hImjUq=C|(bx4?!`q7>s=sh+BYIWVG%XVE_OUKbEM; S_AuoD0000 + + + + + + + + + + diff --git a/flutter/android/app/src/main/res/drawable-xhdpi/ic_notification_white.png b/flutter/android/app/src/main/res/drawable-xhdpi/ic_notification_white.png new file mode 100644 index 0000000000000000000000000000000000000000..125fc41ebd952bc289eb532e13cbe991420e83f1 GIT binary patch literal 551 zcmV+?0@(eDP)>EA7AHgi*&}3;4Y@9{{Id6SSon))^;^#{=}4 z0%5PrcY;@NWH7}OAPK&~gxltuz>8s61UZI~v6h7xaLarNjbTk-0H$Y9>!k4t&<{4i zrvm}**ni0h<9-kWA9Nu$Znqr>!0HY_gGloc@Bn7$qCxrt2GYLkRA)HGTRRDsM44Zp z8OH8i<*hvf^`Ov>`C-APa;d!mQBcWiz6v}^5J1;8=;aBp?1Cwt+7arlhO#kVL=9Km z#_H-+?KxXOtpc!q=2p@GtjkSW1sHW{SlFp;Gz&1B2LYz?J-~Xt521uvfL5?CxIGYA z8!>J8Xd1nj{0T&@W}-#O19Vvh@PS8316;CElFf=dNIwWk79h;i6V1^(on4PtV%8nJ znNFV;(`w4&0DE8!6z94(sG}3S(_>^O+{y5$$1)sC5V_W}cTOQuJG64pN1Kcoz07g~ pcC)5IhpkTg69ppP-;oYaIVW&%hXR$K1(Ga+5hfd=PSPZqEGoF?Y_~vNDlK zBoc{4B9TZW5{X2jtYul&1h4?C20K7I=m5Jx6Icu;h0T;o&;l-lx7NRU!8r8|^n-n1 zekmJM1&)CCmRg;RN@L(ESXye+s11AqufXdZ40;OgffR^=>S8yi2HXaJxc3`CoIhtG z%Bafew-NsQ8@vIH#cB>Mu+PDhAf^P`oCYp|Utm|UnX?3Z;AxR^jDU!bBgn?$5!zNP z=F9~z6>CTX-;unoLH*oDCQ(N;n{G}yxa)F(K^jE*0v4HU&|b@3K^sIGq>eJpm|0*0 z_*!6tXf66lyK+<38oHQAJpW03eEThwL9}Qe2P0r&p^TXgPJv{4a9loCV1wpxhixu| zInCf5cm}p{ur?8{6G{8t$qZ2MQeMlb%ImoeRkH@v1q4DkfXLmG%Gr=HR zltDP{`y&q;T*#ZRT!J&fAm?uT3Yh1YK{tF1&IE%jPP@>ETAQmmD;$e*DH*g5409G> zV=e|=2p~8U406Umd%2Zf%&FkVhL|JhR^Fp@Dv0uLqb_x7xgAxxy`NE)`yIm|XDO%X z8nlKTK33ueo#g9`>Kb$~55wDpDQ4@V)+j>dvmrBx)S_chZwL)Kr(@7S2n|Z;7<4m) z1|?+;`hN^n*&5XDcqgu#YA_+)p&P>kHfUzH2Cd@UZli}}l?e$m-5B=hPO-_3r}(f; zv0>fKVk~3^_2?S3tVEyYRWWSx^3ZxMBnHtzt_m*((eb#?Au#AbE_tD?JkQ0DQW<8((NZSRz>A?`tyMi{qO zHbXAu^xwBK#4ySSF6s(-2AXNklcJ0?VA^d#14WGGZsZ@ul_uqGjPAPm~;ky=gX(xgC9n!upQ}s4pqfl zEZY@+4nN!0iPkFJ$X>Kq6Xks>>hH(!*U2f`*{QW6Afc*wPs{#g`z`z$`D4EVc3E-j zt@2a&+vQKS$XO7D0z8!Q=4t$t=a<%OUcf_j>ur-Rm3iE~63_4_{P!#$i)aDt+=$Cq zf^?)^h)Aezx-CwUMtfO+!ia^c;(aWiFGw@J8n?ZN0`v$^6swl)ucRE)1y~ZnP<6cA zi8tM30Zt)QWiPj$(@cb)DsyO>L)5zU+Pg=JaM(lvmdckML;)Oo^K3oBvvmtljmwIb z_pELKnh>g%mq*qzbqa6?p=x`pbPDhmp=x`t>J*?Ip=x`7kcOfFiS=f=EkGMWHR8>6 zTY%dL)rhy$Z2=k)suAy6w*{z_FDOGx*p+vadMP~X{n}$CeI*L zWp9nivEdA>V*wiq;LOWM|47p_B=TjDfhj-%diAdMYLeCl`GF_4Z4{s@$Gx7axK)0L z&xZYI3IDx3Kk6KSAq|hme9;nVCG(iU-Ut_97U`mde~FYH&l%HMeRNqz;S$t&Pi%5=b_!VOtBRqJ+lX92)VdYGk6r#18KgkOk_Bq_4YODA8NPh8h@a5cL>9_S`+;zdP zj^uS@mr3ufaKQIEUY5mgV&oZvgMzYvn-_PCBh9cnULjc!Fy&YsYr|5K0000000000 l000000000000005{s2NxT@`miEHwZC002ovPDHLkV1kpM>~R19 literal 0 HcmV?d00001 diff --git a/flutter/android/app/src/main/res/raw/keep.xml b/flutter/android/app/src/main/res/raw/keep.xml new file mode 100644 index 0000000..578b09c --- /dev/null +++ b/flutter/android/app/src/main/res/raw/keep.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/flutter/android/build.gradle b/flutter/android/build.gradle index e83fb5d..b7c2752 100644 --- a/flutter/android/build.gradle +++ b/flutter/android/build.gradle @@ -6,6 +6,7 @@ buildscript { } dependencies { + classpath 'com.android.tools.build:gradle:7.3.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } diff --git a/flutter/ios/Runner/AppDelegate.swift b/flutter/ios/Runner/AppDelegate.swift index 70693e4..749b061 100644 --- a/flutter/ios/Runner/AppDelegate.swift +++ b/flutter/ios/Runner/AppDelegate.swift @@ -1,13 +1,27 @@ import UIKit import Flutter +import flutter_local_notifications @UIApplicationMain @objc class AppDelegate: FlutterAppDelegate { + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + + // This is required to make any communication available in the action isolate. + FlutterLocalNotificationsPlugin.setPluginRegistrantCallback { (registry) in + GeneratedPluginRegistrant.register(with: registry) + } + + if #available(iOS 10.0, *) { + UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate + } + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + } diff --git a/flutter/lib/api/api_client.dart b/flutter/lib/api/api_client.dart index 7d4d8d8..ceb3f25 100644 --- a/flutter/lib/api/api_client.dart +++ b/flutter/lib/api/api_client.dart @@ -11,7 +11,7 @@ import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/globals.dart'; import 'package:simplecloudnotifier/state/request_log.dart'; import 'package:simplecloudnotifier/models/channel.dart'; -import 'package:simplecloudnotifier/models/message.dart'; +import 'package:simplecloudnotifier/models/scn_message.dart'; import 'package:simplecloudnotifier/state/token_source.dart'; import 'package:simplecloudnotifier/utils/toaster.dart'; @@ -211,7 +211,7 @@ class APIClient { ); } - static Future<(String, List)> getMessageList(TokenSource auth, String pageToken, {int? pageSize, List? channelIDs}) async { + static Future<(String, List)> getMessageList(TokenSource auth, String pageToken, {int? pageSize, List? channelIDs}) async { return await _request( name: 'getMessageList', method: 'GET', @@ -221,18 +221,18 @@ class APIClient { if (pageSize != null) 'page_size': pageSize.toString(), if (channelIDs != null) 'channel_id': channelIDs.join(","), }, - fn: (json) => Message.fromPaginatedJsonArray(json, 'messages', 'next_page_token'), + fn: (json) => SCNMessage.fromPaginatedJsonArray(json, 'messages', 'next_page_token'), authToken: auth.getToken(), ); } - static Future getMessage(TokenSource auth, String msgid) async { + static Future getMessage(TokenSource auth, String msgid) async { return await _request( name: 'getMessage', method: 'GET', relURL: 'messages/$msgid', query: {}, - fn: Message.fromJson, + fn: SCNMessage.fromJson, authToken: auth.getToken(), ); } diff --git a/flutter/lib/components/layout/app_bar.dart b/flutter/lib/components/layout/app_bar.dart index 165e0fc..c0e63ed 100644 --- a/flutter/lib/components/layout/app_bar.dart +++ b/flutter/lib/components/layout/app_bar.dart @@ -1,9 +1,9 @@ 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/settings/app_settings.dart'; import 'package:simplecloudnotifier/state/app_theme.dart'; import 'package:simplecloudnotifier/utils/navi.dart'; @@ -12,7 +12,6 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget { Key? key, required this.title, required this.showThemeSwitch, - required this.showDebug, required this.showSearch, required this.showShare, this.onShare = null, @@ -20,16 +19,17 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget { final String? title; final bool showThemeSwitch; - final bool showDebug; final bool showSearch; final bool showShare; final void Function()? onShare; @override Widget build(BuildContext context) { + final cfg = Provider.of(context); + var actions = []; - if (showDebug) { + if (cfg.showDebugButton) { actions.add(IconButton( icon: const Icon(FontAwesomeIcons.solidSpiderBlackWidow), tooltip: 'Debug', diff --git a/flutter/lib/components/layout/scaffold.dart b/flutter/lib/components/layout/scaffold.dart index ad8aeed..aaf44fc 100644 --- a/flutter/lib/components/layout/scaffold.dart +++ b/flutter/lib/components/layout/scaffold.dart @@ -7,7 +7,6 @@ class SCNScaffold extends StatelessWidget { required this.child, this.title, this.showThemeSwitch = true, - this.showDebug = true, this.showSearch = true, this.showShare = false, this.onShare = null, @@ -16,7 +15,6 @@ class SCNScaffold extends StatelessWidget { final Widget child; final String? title; final bool showThemeSwitch; - final bool showDebug; final bool showSearch; final bool showShare; final void Function()? onShare; @@ -27,7 +25,6 @@ class SCNScaffold extends StatelessWidget { appBar: SCNAppBar( title: title, showThemeSwitch: showThemeSwitch, - showDebug: showDebug, showSearch: showSearch, showShare: showShare, onShare: onShare ?? () {}, diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index 8c960c5..6d540c2 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -2,13 +2,15 @@ import 'dart:io'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.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/models/scn_message.dart'; import 'package:simplecloudnotifier/nav_layout.dart'; +import 'package:simplecloudnotifier/settings/app_settings.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/app_theme.dart'; import 'package:simplecloudnotifier/state/application_log.dart'; @@ -18,6 +20,7 @@ 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:simplecloudnotifier/utils/notifier.dart'; import 'package:toastification/toastification.dart'; import 'firebase_options.dart'; @@ -39,20 +42,10 @@ void main() async { Hive.registerAdapter(SCNRequestAdapter()); Hive.registerAdapter(SCNLogAdapter()); Hive.registerAdapter(SCNLogLevelAdapter()); - Hive.registerAdapter(MessageAdapter()); + Hive.registerAdapter(SCNMessageAdapter()); Hive.registerAdapter(ChannelAdapter()); Hive.registerAdapter(FBMessageAdapter()); - print('[INIT] Load Hive...'); - - try { - await Hive.openBox('scn-requests'); - } catch (exc, trace) { - Hive.deleteBoxFromDisk('scn-requests'); - await Hive.openBox('scn-requests'); - ApplicationLog.error('Failed to open Hive-Box: scn-requests: ' + exc.toString(), trace: trace); - } - print('[INIT] Load Hive...'); try { @@ -63,13 +56,23 @@ 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-requests'); + } catch (exc, trace) { + Hive.deleteBoxFromDisk('scn-requests'); + await Hive.openBox('scn-requests'); + ApplicationLog.error('Failed to open Hive-Box: scn-requests: ' + exc.toString(), trace: trace); + } + print('[INIT] Load Hive...'); try { - await Hive.openBox('scn-message-cache'); + await Hive.openBox('scn-message-cache'); } catch (exc, trace) { Hive.deleteBoxFromDisk('scn-message-cache'); - await Hive.openBox('scn-message-cache'); + await Hive.openBox('scn-message-cache'); ApplicationLog.error('Failed to open Hive-Box: scn-message-cache' + exc.toString(), trace: trace); } @@ -147,6 +150,38 @@ void main() async { print('[INIT] Skip Firebase init (Platform == Linux)...'); } + print('[INIT] Load Notifications...'); + + final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + final flutterLocalNotificationsPluginImpl = flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation(); + if (flutterLocalNotificationsPluginImpl == null) { + ApplicationLog.error('Failed to get AndroidFlutterLocalNotificationsPlugin', trace: StackTrace.current); + } else { + flutterLocalNotificationsPluginImpl.requestNotificationsPermission(); + + final initializationSettingsAndroid = AndroidInitializationSettings('ic_notification_white'); + final initializationSettingsDarwin = DarwinInitializationSettings( + requestAlertPermission: true, + requestBadgePermission: true, + requestSoundPermission: true, + onDidReceiveLocalNotification: _receiveLocalDarwinNotification, + notificationCategories: getDarwinNotificationCategories(), + ); + final initializationSettingsLinux = LinuxInitializationSettings(defaultActionName: 'Open notification'); + final initializationSettings = InitializationSettings( + android: initializationSettingsAndroid, + iOS: initializationSettingsDarwin, + linux: initializationSettingsLinux, + ); + flutterLocalNotificationsPlugin.initialize(initializationSettings, onDidReceiveNotificationResponse: _receiveLocalNotification); + + final appLaunchNotification = await flutterLocalNotificationsPlugin.getNotificationAppLaunchDetails(); + if (appLaunchNotification != null) { + //TODO show message (also this only works on android+localnotifications, also handle ios) + ApplicationLog.info('App launched by notification: ${appLaunchNotification.notificationResponse?.id}'); + } + } + ApplicationLog.debug('[INIT] Application started'); runApp( @@ -155,6 +190,7 @@ void main() async { ChangeNotifierProvider(create: (context) => AppAuth(), lazy: false), ChangeNotifierProvider(create: (context) => AppTheme(), lazy: false), ChangeNotifierProvider(create: (context) => AppBarState(), lazy: false), + ChangeNotifierProvider(create: (context) => AppSettings(), lazy: false), ], child: SCNApp(), ), @@ -188,6 +224,11 @@ class SCNApp extends StatelessWidget { } } +@pragma('vm:entry-point') +void notificationTapBackground(NotificationResponse notificationResponse) { + ApplicationLog.info('Received local notification: ${notificationResponse.id}'); +} + void setFirebaseToken(String fcmToken) async { final acc = AppAuth(); @@ -233,9 +274,96 @@ void _onForegroundMessage(RemoteMessage message) { } Future _receiveMessage(RemoteMessage message, bool foreground) async { - // ensure init - Hive.openBox('scn-logs'); + try { + // ensure globals init + if (!Globals().isInitialized) { + print('[LATE-INIT] Init Globals() - to ensure working _receiveMessage($foreground)...'); + await Globals().init(); + } + + // ensure hive init + if (!Hive.isBoxOpen('scn-logs')) { + print('[LATE-INIT] Init Hive - to ensure working _receiveMessage($foreground)...'); + + await Hive.initFlutter(); + Hive.registerAdapter(SCNRequestAdapter()); + Hive.registerAdapter(SCNLogAdapter()); + Hive.registerAdapter(SCNLogLevelAdapter()); + Hive.registerAdapter(SCNMessageAdapter()); + Hive.registerAdapter(ChannelAdapter()); + Hive.registerAdapter(FBMessageAdapter()); + } + + print('[LATE-INIT] Ensure hive boxes are open for _receiveMessage($foreground)...'); + + await Hive.openBox('scn-logs'); + await Hive.openBox('scn-fb-messages'); + await Hive.openBox('scn-message-cache'); + } catch (exc, trace) { + ApplicationLog.error('Failed to init hive:' + exc.toString(), trace: trace); + Notifier.showLocalNotification("@ERROR", "@ERROR", 'Error Channel', "Error", "Failed to receive SCN message (init failed)", null); + return; + } ApplicationLog.info('Received FB message (${foreground ? 'foreground' : 'background'}): ${message.messageId ?? 'NULL'}'); - FBMessageLog.insert(message); + + SCNMessage? receivedMessage; + + try { + final scn_msg_id = message.data['scn_msg_id'] as String; + final usr_msg_id = message.data['usr_msg_id'] as String; + final timestamp = DateTime.fromMillisecondsSinceEpoch(int.parse(message.data['timestamp'] as String) * 1000); + final priority = int.parse(message.data['priority'] as String); + final title = message.data['title'] as String; + final channel = message.data['channel'] as String; + final channel_id = message.data['channel_id'] as String; + final body = message.data['body'] as String; + + Notifier.showLocalNotification(channel_id, channel, 'Channel: ${channel}', title, body, timestamp); + + receivedMessage = SCNMessage( + messageID: scn_msg_id, + userMessageID: usr_msg_id, + timestamp: timestamp.toIso8601String(), + priority: priority, + trimmed: true, + title: title, + channelID: channel_id, + channelInternalName: channel, + content: body, + senderIP: '', + senderName: '', + senderUserID: '', + usedKeyID: '', + ); + } catch (exc, trace) { + ApplicationLog.error('Failed to decode received FB message' + exc.toString(), trace: trace); + Notifier.showLocalNotification("@ERROR", "@ERROR", 'Error Channel', "Error", "Failed to receive SCN message (decode failed)", null); + return; + } + + try { + FBMessageLog.insert(message); + } catch (exc, trace) { + ApplicationLog.error('Failed to persist received FB message' + exc.toString(), trace: trace); + Notifier.showLocalNotification("@ERROR", "@ERROR", 'Error Channel', "Error", "Failed to receive SCN message (persist failed)", null); + return; + } + + //TODO add to scn-message-cache + //TODO refresh message_list view (if shown/initialized) +} + +void _receiveLocalDarwinNotification(int id, String? title, String? body, String? payload) { + ApplicationLog.info('Received local notification: $id -> [$title]'); +} + +void _receiveLocalNotification(NotificationResponse details) { + ApplicationLog.info('Received local notification: ${details.id}'); +} + +List getDarwinNotificationCategories() { + return [ + //TODO ?!? + ]; } diff --git a/flutter/lib/models/message.dart b/flutter/lib/models/scn_message.dart similarity index 84% rename from flutter/lib/models/message.dart rename to flutter/lib/models/scn_message.dart index 4d4b18c..1b2a8b1 100644 --- a/flutter/lib/models/message.dart +++ b/flutter/lib/models/scn_message.dart @@ -1,10 +1,10 @@ import 'package:hive_flutter/hive_flutter.dart'; import 'package:simplecloudnotifier/state/interfaces.dart'; -part 'message.g.dart'; +part 'scn_message.g.dart'; @HiveType(typeId: 105) -class Message extends HiveObject implements FieldDebuggable { +class SCNMessage extends HiveObject implements FieldDebuggable { @HiveField(0) final String messageID; @@ -33,7 +33,7 @@ class Message extends HiveObject implements FieldDebuggable { @HiveField(21) final bool trimmed; - Message({ + SCNMessage({ required this.messageID, required this.senderUserID, required this.channelInternalName, @@ -49,8 +49,8 @@ class Message extends HiveObject implements FieldDebuggable { required this.trimmed, }); - factory Message.fromJson(Map json) { - return Message( + factory SCNMessage.fromJson(Map json) { + return SCNMessage( messageID: json['message_id'] as String, senderUserID: json['sender_user_id'] as String, channelInternalName: json['channel_internal_name'] as String, @@ -67,10 +67,10 @@ class Message extends HiveObject implements FieldDebuggable { ); } - static (String, List) fromPaginatedJsonArray(Map data, String keyMessages, String keyToken) { + static (String, List) fromPaginatedJsonArray(Map data, String keyMessages, String keyToken) { final npt = data[keyToken] as String; - final messages = (data[keyMessages] as List).map((e) => Message.fromJson(e as Map)).toList(); + final messages = (data[keyMessages] as List).map((e) => SCNMessage.fromJson(e as Map)).toList(); return (npt, messages); } diff --git a/flutter/lib/models/message.g.dart b/flutter/lib/models/scn_message.g.dart similarity index 88% rename from flutter/lib/models/message.g.dart rename to flutter/lib/models/scn_message.g.dart index 6bfc06a..cf8746d 100644 --- a/flutter/lib/models/message.g.dart +++ b/flutter/lib/models/scn_message.g.dart @@ -1,22 +1,22 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'message.dart'; +part of 'scn_message.dart'; // ************************************************************************** // TypeAdapterGenerator // ************************************************************************** -class MessageAdapter extends TypeAdapter { +class SCNMessageAdapter extends TypeAdapter { @override final int typeId = 105; @override - Message read(BinaryReader reader) { + SCNMessage read(BinaryReader reader) { final numOfFields = reader.readByte(); final fields = { for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), }; - return Message( + return SCNMessage( messageID: fields[0] as String, senderUserID: fields[10] as String, channelInternalName: fields[11] as String, @@ -34,7 +34,7 @@ class MessageAdapter extends TypeAdapter { } @override - void write(BinaryWriter writer, Message obj) { + void write(BinaryWriter writer, SCNMessage obj) { writer ..writeByte(13) ..writeByte(0) @@ -71,7 +71,7 @@ class MessageAdapter extends TypeAdapter { @override bool operator ==(Object other) => identical(this, other) || - other is MessageAdapter && + other is SCNMessageAdapter && runtimeType == other.runtimeType && typeId == other.typeId; } diff --git a/flutter/lib/nav_layout.dart b/flutter/lib/nav_layout.dart index b5265e7..b08b90f 100644 --- a/flutter/lib/nav_layout.dart +++ b/flutter/lib/nav_layout.dart @@ -59,7 +59,6 @@ class _SCNNavLayoutState extends State { return Scaffold( appBar: SCNAppBar( title: null, - showDebug: true, showSearch: _selectedIndex == 0 || _selectedIndex == 1, showShare: false, showThemeSwitch: true, diff --git a/flutter/lib/pages/channel_list/channel_list_item.dart b/flutter/lib/pages/channel_list/channel_list_item.dart index a400015..ddadde7 100644 --- a/flutter/lib/pages/channel_list/channel_list_item.dart +++ b/flutter/lib/pages/channel_list/channel_list_item.dart @@ -3,7 +3,7 @@ import 'package:intl/intl.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/models/scn_message.dart'; import 'package:simplecloudnotifier/state/app_auth.dart'; class ChannelListItem extends StatefulWidget { @@ -23,7 +23,7 @@ class ChannelListItem extends StatefulWidget { } class _ChannelListItemState extends State { - Message? lastMessage; + SCNMessage? lastMessage; @override void initState() { diff --git a/flutter/lib/pages/debug/debug_actions.dart b/flutter/lib/pages/debug/debug_actions.dart index 79d0937..e658e0e 100644 --- a/flutter/lib/pages/debug/debug_actions.dart +++ b/flutter/lib/pages/debug/debug_actions.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:simplecloudnotifier/utils/notifier.dart'; import 'package:simplecloudnotifier/utils/toaster.dart'; import 'package:simplecloudnotifier/utils/ui.dart'; @@ -52,6 +53,12 @@ class _DebugActionsPageState extends State { onPressed: _sendTokenToServer, text: 'Send FCM Token to Server', ), + SizedBox(height: 20), + UI.button( + big: false, + onPressed: () => Notifier.showLocalNotification('TEST_CHANNEL', "Test Channel", "Channel for testing", "Hello World", "Local Notification test", null), + text: 'Show local notification', + ), ], ), ), diff --git a/flutter/lib/pages/debug/debug_main.dart b/flutter/lib/pages/debug/debug_main.dart index 3b0b957..bbbd48a 100644 --- a/flutter/lib/pages/debug/debug_main.dart +++ b/flutter/lib/pages/debug/debug_main.dart @@ -30,7 +30,6 @@ class _DebugMainPageState extends State { return SCNScaffold( title: 'Debug', showSearch: false, - showDebug: false, child: Column( children: [ Padding( diff --git a/flutter/lib/pages/debug/debug_persistence.dart b/flutter/lib/pages/debug/debug_persistence.dart index 44d3328..1daaec9 100644 --- a/flutter/lib/pages/debug/debug_persistence.dart +++ b/flutter/lib/pages/debug/debug_persistence.dart @@ -2,7 +2,7 @@ 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/models/scn_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'; @@ -36,7 +36,7 @@ class _DebugPersistencePageState extends State { _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-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'), ], @@ -71,7 +71,7 @@ class _DebugPersistencePageState extends State { padding: const EdgeInsets.all(8.0), child: GestureDetector( onTap: () { - Navi.push(context, () => DebugHiveBoxPage(boxName: boxname, box: Hive.box(boxname))); + Navi.push(context, () => DebugHiveBoxPage(boxName: boxname, box: boxFunc())); }, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, diff --git a/flutter/lib/pages/debug/debug_persistence_hive.dart b/flutter/lib/pages/debug/debug_persistence_hive.dart index 12aefd2..1aca642 100644 --- a/flutter/lib/pages/debug/debug_persistence_hive.dart +++ b/flutter/lib/pages/debug/debug_persistence_hive.dart @@ -16,7 +16,6 @@ class DebugHiveBoxPage extends StatelessWidget { return SCNScaffold( title: 'Hive: ' + boxName, showSearch: false, - showDebug: false, child: ListView.separated( itemCount: box.length, itemBuilder: (context, listIndex) { @@ -24,8 +23,9 @@ class DebugHiveBoxPage extends StatelessWidget { onTap: () { Navi.push(context, () => DebugHiveEntryPage(value: box.getAt(listIndex)!)); }, - child: ListTile( - title: Text(box.getAt(listIndex).toString(), style: TextStyle(fontWeight: FontWeight.bold)), + child: Container( + padding: EdgeInsets.fromLTRB(8, 4, 8, 4), + child: Text(box.getAt(listIndex).toString(), style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)), ), ); }, diff --git a/flutter/lib/pages/debug/debug_persistence_hiveentry.dart b/flutter/lib/pages/debug/debug_persistence_hiveentry.dart index 52a2e52..6cd975f 100644 --- a/flutter/lib/pages/debug/debug_persistence_hiveentry.dart +++ b/flutter/lib/pages/debug/debug_persistence_hiveentry.dart @@ -13,11 +13,13 @@ class DebugHiveEntryPage extends StatelessWidget { return SCNScaffold( title: 'HiveEntry', showSearch: false, - showDebug: false, child: ListView.separated( itemCount: fields.length, itemBuilder: (context, listIndex) { return ListTile( + dense: true, + contentPadding: EdgeInsets.fromLTRB(8, 0, 8, 0), + visualDensity: VisualDensity(horizontal: 0, vertical: -4), title: Text(fields[listIndex].$1, style: TextStyle(fontWeight: FontWeight.bold)), subtitle: Text(fields[listIndex].$2, style: TextStyle(fontFamily: "monospace")), ); diff --git a/flutter/lib/pages/debug/debug_persistence_sharedprefs.dart b/flutter/lib/pages/debug/debug_persistence_sharedprefs.dart index d927dbe..c0cadab 100644 --- a/flutter/lib/pages/debug/debug_persistence_sharedprefs.dart +++ b/flutter/lib/pages/debug/debug_persistence_sharedprefs.dart @@ -13,7 +13,6 @@ class DebugSharedPrefPage extends StatelessWidget { return SCNScaffold( title: 'SharedPreferences', showSearch: false, - showDebug: false, child: ListView.separated( itemCount: sharedPref.getKeys().length, itemBuilder: (context, listIndex) { diff --git a/flutter/lib/pages/debug/debug_request_view.dart b/flutter/lib/pages/debug/debug_request_view.dart index b6c7eed..9e5d804 100644 --- a/flutter/lib/pages/debug/debug_request_view.dart +++ b/flutter/lib/pages/debug/debug_request_view.dart @@ -16,7 +16,6 @@ class DebugRequestViewPage extends StatelessWidget { return SCNScaffold( title: 'Request', showSearch: false, - showDebug: false, child: SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(8.0), diff --git a/flutter/lib/pages/message_list/message_list.dart b/flutter/lib/pages/message_list/message_list.dart index 8e91212..fe094dc 100644 --- a/flutter/lib/pages/message_list/message_list.dart +++ b/flutter/lib/pages/message_list/message_list.dart @@ -4,8 +4,9 @@ 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/models/scn_message.dart'; import 'package:simplecloudnotifier/pages/message_view/message_view.dart'; +import 'package:simplecloudnotifier/settings/app_settings.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/app_auth.dart'; @@ -25,11 +26,9 @@ class MessageListPage extends StatefulWidget { } class _MessageListPageState extends State with RouteAware { - static const _pageSize = 128; - late final AppLifecycleListener _lifecyleListener; - PagingController _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: '@start'); + PagingController _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: '@start'); Map? _channels = null; @@ -65,7 +64,7 @@ class _MessageListPageState extends State with RouteAware { ApplicationLog.debug('MessageListPage::_realInitState'); final chnCache = Hive.box('scn-channel-cache'); - final msgCache = Hive.box('scn-message-cache'); + final msgCache = Hive.box('scn-message-cache'); if (chnCache.isNotEmpty && msgCache.isNotEmpty) { // ==== Use cache values - and refresh in background @@ -119,6 +118,7 @@ class _MessageListPageState extends State with RouteAware { Future _fetchPage(String thisPageToken) async { final acc = Provider.of(context, listen: false); + final cfg = Provider.of(context, listen: false); ApplicationLog.debug('Start MessageList::_pagingController::_fetchPage [ ${thisPageToken} ]'); @@ -135,7 +135,7 @@ class _MessageListPageState extends State with RouteAware { _setChannelCache(channels); // no await } - final (npt, newItems) = await APIClient.getMessageList(acc, thisPageToken, pageSize: _pageSize); + final (npt, newItems) = await APIClient.getMessageList(acc, thisPageToken, pageSize: cfg.messagePageSize); _addToMessageCache(newItems); // no await @@ -154,6 +154,7 @@ class _MessageListPageState extends State with RouteAware { Future _backgroundRefresh(bool fullReplaceState) async { final acc = Provider.of(context, listen: false); + final cfg = Provider.of(context, listen: false); ApplicationLog.debug('Start background refresh of message list (fullReplaceState: $fullReplaceState)'); @@ -170,7 +171,7 @@ class _MessageListPageState extends State with RouteAware { _setChannelCache(channels); // no await } - final (npt, newItems) = await APIClient.getMessageList(acc, '@start', pageSize: _pageSize); + final (npt, newItems) = await APIClient.getMessageList(acc, '@start', pageSize: cfg.messagePageSize); _addToMessageCache(newItems); // no await @@ -225,9 +226,9 @@ class _MessageListPageState extends State with RouteAware { onRefresh: () => Future.sync( () => _pagingController.refresh(), ), - child: PagedListView( + child: PagedListView( pagingController: _pagingController, - builderDelegate: PagedChildBuilderDelegate( + builderDelegate: PagedChildBuilderDelegate( itemBuilder: (context, item, index) => MessageListItem( message: item, allChannels: _channels ?? {}, @@ -249,20 +250,22 @@ class _MessageListPageState extends State with RouteAware { 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'); + Future _addToMessageCache(List newItems) async { + final cfg = AppSettings(); + + 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; + if (cache.length < cfg.messagePageSize) return; final allValues = cache.values.toList(); allValues.sort((a, b) => -1 * a.timestamp.compareTo(b.timestamp)); - for (var val in allValues.sublist(_pageSize)) { + for (var val in allValues.sublist(cfg.messagePageSize)) { 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 44f01fd..635c129 100644 --- a/flutter/lib/pages/message_list/message_list_item.dart +++ b/flutter/lib/pages/message_list/message_list_item.dart @@ -3,7 +3,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:simplecloudnotifier/models/channel.dart'; -import 'package:simplecloudnotifier/models/message.dart'; +import 'package:simplecloudnotifier/models/scn_message.dart'; import 'package:intl/intl.dart'; import 'package:simplecloudnotifier/utils/ui.dart'; @@ -18,7 +18,7 @@ class MessageListItem extends StatelessWidget { super.key, }); - final Message message; + final SCNMessage message; final Map allChannels; final Null Function() onPressed; @@ -176,11 +176,11 @@ class MessageListItem extends StatelessWidget { return v; } - String resolveChannelName(Message message) { + String resolveChannelName(SCNMessage message) { return allChannels[message.channelID]?.displayName ?? message.channelInternalName; } - bool showChannel(Message message) { + bool showChannel(SCNMessage message) { return message.channelInternalName != 'main'; } } diff --git a/flutter/lib/pages/message_view/message_view.dart b/flutter/lib/pages/message_view/message_view.dart index f266ba2..29c51c9 100644 --- a/flutter/lib/pages/message_view/message_view.dart +++ b/flutter/lib/pages/message_view/message_view.dart @@ -8,7 +8,7 @@ import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/components/layout/scaffold.dart'; import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/keytoken.dart'; -import 'package:simplecloudnotifier/models/message.dart'; +import 'package:simplecloudnotifier/models/scn_message.dart'; import 'package:simplecloudnotifier/models/user.dart'; import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart'; @@ -18,15 +18,15 @@ import 'package:simplecloudnotifier/utils/ui.dart'; class MessageViewPage extends StatefulWidget { const MessageViewPage({super.key, required this.message}); - final Message message; // Potentially trimmed + final SCNMessage message; // Potentially trimmed @override State createState() => _MessageViewPageState(); } class _MessageViewPageState extends State { - late Future<(Message, ChannelPreview, KeyTokenPreview, UserPreview)>? mainFuture; - (Message, ChannelPreview, KeyTokenPreview, UserPreview)? mainFutureSnapshot = null; + late Future<(SCNMessage, ChannelPreview, KeyTokenPreview, UserPreview)>? mainFuture; + (SCNMessage, ChannelPreview, KeyTokenPreview, UserPreview)? mainFutureSnapshot = null; static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm'); bool _monospaceMode = false; @@ -37,7 +37,7 @@ class _MessageViewPageState extends State { super.initState(); } - Future<(Message, ChannelPreview, KeyTokenPreview, UserPreview)> fetchData() async { + Future<(SCNMessage, ChannelPreview, KeyTokenPreview, UserPreview)> fetchData() async { try { await Future.delayed(const Duration(seconds: 0), () {}); // this is annoyingly important - otherwise we call setLoadingIndeterminate directly in initStat() and get an exception.... @@ -79,7 +79,7 @@ class _MessageViewPageState extends State { showSearch: false, showShare: true, onShare: _share, - child: FutureBuilder<(Message, ChannelPreview, KeyTokenPreview, UserPreview)>( + child: FutureBuilder<(SCNMessage, ChannelPreview, KeyTokenPreview, UserPreview)>( future: mainFuture, builder: (context, snapshot) { if (snapshot.hasData) { @@ -118,7 +118,7 @@ class _MessageViewPageState extends State { } } - Widget _buildMessageView(BuildContext context, Message message, ChannelPreview? channel, KeyTokenPreview? token, UserPreview? user) { + Widget _buildMessageView(BuildContext context, SCNMessage message, ChannelPreview? channel, KeyTokenPreview? token, UserPreview? user) { final userAccUserID = context.select((v) => v.userID); return SingleChildScrollView( @@ -144,11 +144,11 @@ class _MessageViewPageState extends State { ); } - String _resolveChannelName(ChannelPreview? channel, Message message) { + String _resolveChannelName(ChannelPreview? channel, SCNMessage message) { return channel?.displayName ?? message.channelInternalName; } - List _buildMessageHeader(BuildContext context, Message message, ChannelPreview? channel) { + List _buildMessageHeader(BuildContext context, SCNMessage message, ChannelPreview? channel) { return [ Row( children: [ @@ -167,7 +167,7 @@ class _MessageViewPageState extends State { ]; } - List _buildMessageContent(BuildContext context, Message message) { + List _buildMessageContent(BuildContext context, SCNMessage message) { return [ Row( children: [ @@ -249,7 +249,7 @@ class _MessageViewPageState extends State { } } - String _preformatTitle(Message message) { + String _preformatTitle(SCNMessage message) { return message.title.replaceAll('\n', '').replaceAll('\r', '').replaceAll('\t', ' '); } } diff --git a/flutter/lib/settings/app_settings.dart b/flutter/lib/settings/app_settings.dart new file mode 100644 index 0000000..a487fed --- /dev/null +++ b/flutter/lib/settings/app_settings.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +class AppSettings extends ChangeNotifier { + bool groupNotifications = true; + int messagePageSize = 128; + bool showDebugButton = true; + + static AppSettings? _singleton = AppSettings._internal(); + + factory AppSettings() { + return _singleton ?? (_singleton = AppSettings._internal()); + } + + AppSettings._internal() { + load(); + } + + void clear() { + //TODO + + notifyListeners(); + } + + void load() { + //TODO + + notifyListeners(); + } + + Future save() async { + //TODO + } +} diff --git a/flutter/lib/state/application_log.dart b/flutter/lib/state/application_log.dart index d997a7a..f5ae651 100644 --- a/flutter/lib/state/application_log.dart +++ b/flutter/lib/state/application_log.dart @@ -10,6 +10,7 @@ class ApplicationLog { static void debug(String message, {String? additional, StackTrace? trace}) { (additional != null && additional != '') ? print('[DEBUG] ${message}: ${additional}') : print('[DEBUG] ${message}'); + if (!Hive.isBoxOpen('scn-logs')) return; Hive.box('scn-logs').add(SCNLog( id: Xid().toString(), timestamp: DateTime.now(), @@ -23,6 +24,7 @@ class ApplicationLog { static void info(String message, {String? additional, StackTrace? trace}) { (additional != null && additional != '') ? print('[INFO] ${message}: ${additional}') : print('[INFO] ${message}'); + if (!Hive.isBoxOpen('scn-logs')) return; Hive.box('scn-logs').add(SCNLog( id: Xid().toString(), timestamp: DateTime.now(), @@ -36,6 +38,7 @@ class ApplicationLog { static void warn(String message, {String? additional, StackTrace? trace}) { (additional != null && additional != '') ? print('[WARN] ${message}: ${additional}') : print('[WARN] ${message}'); + if (!Hive.isBoxOpen('scn-logs')) return; Hive.box('scn-logs').add(SCNLog( id: Xid().toString(), timestamp: DateTime.now(), @@ -49,6 +52,7 @@ class ApplicationLog { static void error(String message, {String? additional, StackTrace? trace}) { (additional != null && additional != '') ? print('[ERROR] ${message}: ${additional}') : print('[ERROR] ${message}'); + if (!Hive.isBoxOpen('scn-logs')) return; Hive.box('scn-logs').add(SCNLog( id: Xid().toString(), timestamp: DateTime.now(), @@ -62,6 +66,7 @@ class ApplicationLog { static void fatal(String message, {String? additional, StackTrace? trace}) { (additional != null && additional != '') ? print('[FATAL] ${message}: ${additional}') : print('[FATAL] ${message}'); + if (!Hive.isBoxOpen('scn-logs')) return; Hive.box('scn-logs').add(SCNLog( id: Xid().toString(), timestamp: DateTime.now(), diff --git a/flutter/lib/state/fb_message.dart b/flutter/lib/state/fb_message.dart index b57e372..bd9782b 100644 --- a/flutter/lib/state/fb_message.dart +++ b/flutter/lib/state/fb_message.dart @@ -32,8 +32,6 @@ class FBMessage extends HiveObject implements FieldDebuggable { final String? messageType; @HiveField(8) final bool mutableContent; - @HiveField(9) - final RemoteNotification? notification; @HiveField(10) final DateTime? sentTime; @HiveField(11) @@ -54,7 +52,7 @@ class FBMessage extends HiveObject implements FieldDebuggable { @HiveField(25) final String? notificationAndroidLink; @HiveField(26) - final AndroidNotificationPriority? notificationAndroidPriority; + final String? notificationAndroidPriority; @HiveField(27) final String? notificationAndroidSmallIcon; @HiveField(28) @@ -62,14 +60,14 @@ class FBMessage extends HiveObject implements FieldDebuggable { @HiveField(29) final String? notificationAndroidTicker; @HiveField(30) - final AndroidNotificationVisibility? notificationAndroidVisibility; + final String? notificationAndroidVisibility; @HiveField(31) final String? notificationAndroidTag; @HiveField(40) final String? notificationAppleBadge; @HiveField(41) - final AppleNotificationSound? notificationAppleSound; + final String? notificationAppleSound; @HiveField(42) final String? notificationAppleImageUrl; @HiveField(43) @@ -109,7 +107,6 @@ class FBMessage extends HiveObject implements FieldDebuggable { required this.messageId, required this.messageType, required this.mutableContent, - required this.notification, required this.sentTime, required this.threadId, required this.ttl, @@ -152,7 +149,6 @@ class FBMessage extends HiveObject implements FieldDebuggable { 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, @@ -162,14 +158,14 @@ class FBMessage extends HiveObject implements FieldDebuggable { 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.notificationAndroidPriority = rmsg.notification?.android?.priority?.toString(), 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.notificationAndroidVisibility = rmsg.notification?.android?.visibility?.toString(), this.notificationAndroidTag = rmsg.notification?.android?.tag, this.notificationAppleBadge = rmsg.notification?.apple?.badge, - this.notificationAppleSound = rmsg.notification?.apple?.sound, + this.notificationAppleSound = rmsg.notification?.apple?.sound?.toString(), this.notificationAppleImageUrl = rmsg.notification?.apple?.imageUrl, this.notificationAppleSubtitle = rmsg.notification?.apple?.subtitle, this.notificationAppleSubtitleLocArgs = rmsg.notification?.apple?.subtitleLocArgs, @@ -195,12 +191,11 @@ class FBMessage extends HiveObject implements FieldDebuggable { ('category', this.category ?? ''), ('collapseKey', this.collapseKey ?? ''), ('contentAvailable', this.contentAvailable.toString()), - ('data', this.data.toString()), + ('data', this.data.entries.map((e) => '${e.key} := ${e.value}').join('\n')), ('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() ?? ''), diff --git a/flutter/lib/state/fb_message.g.dart b/flutter/lib/state/fb_message.g.dart index e10f640..9cd1bc9 100644 --- a/flutter/lib/state/fb_message.g.dart +++ b/flutter/lib/state/fb_message.g.dart @@ -26,7 +26,6 @@ class FBMessageAdapter extends TypeAdapter { 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?, @@ -36,15 +35,14 @@ class FBMessageAdapter extends TypeAdapter { notificationAndroidCount: fields[23] as int?, notificationAndroidImageUrl: fields[24] as String?, notificationAndroidLink: fields[25] as String?, - notificationAndroidPriority: fields[26] as AndroidNotificationPriority?, + notificationAndroidPriority: fields[26] as String?, notificationAndroidSmallIcon: fields[27] as String?, notificationAndroidSound: fields[28] as String?, notificationAndroidTicker: fields[29] as String?, - notificationAndroidVisibility: - fields[30] as AndroidNotificationVisibility?, + notificationAndroidVisibility: fields[30] as String?, notificationAndroidTag: fields[31] as String?, notificationAppleBadge: fields[40] as String?, - notificationAppleSound: fields[41] as AppleNotificationSound?, + notificationAppleSound: fields[41] as String?, notificationAppleImageUrl: fields[42] as String?, notificationAppleSubtitle: fields[43] as String?, notificationAppleSubtitleLocArgs: (fields[44] as List?)?.cast(), @@ -64,7 +62,7 @@ class FBMessageAdapter extends TypeAdapter { @override void write(BinaryWriter writer, FBMessage obj) { writer - ..writeByte(40) + ..writeByte(39) ..writeByte(0) ..write(obj.senderId) ..writeByte(1) @@ -83,8 +81,6 @@ class FBMessageAdapter extends TypeAdapter { ..write(obj.messageType) ..writeByte(8) ..write(obj.mutableContent) - ..writeByte(9) - ..write(obj.notification) ..writeByte(10) ..write(obj.sentTime) ..writeByte(11) diff --git a/flutter/lib/state/globals.dart b/flutter/lib/state/globals.dart index 572b16c..d54fa56 100644 --- a/flutter/lib/state/globals.dart +++ b/flutter/lib/state/globals.dart @@ -1,3 +1,4 @@ +import 'dart:ffi'; import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; @@ -13,6 +14,8 @@ class Globals { Globals._internal(); + bool _initialized = false; + String appName = ''; String packageName = ''; String version = ''; @@ -24,7 +27,11 @@ class Globals { late SharedPreferences sharedPrefs; + bool get isInitialized => _initialized; + Future init() async { + if (_initialized) return; + PackageInfo packageInfo = await PackageInfo.fromPlatform(); this.appName = packageInfo.appName; @@ -54,6 +61,8 @@ class Globals { } this.sharedPrefs = await SharedPreferences.getInstance(); + + this._initialized = true; } String? getPrefFCMToken() { diff --git a/flutter/lib/utils/notifier.dart b/flutter/lib/utils/notifier.dart new file mode 100644 index 0000000..0abb3ce --- /dev/null +++ b/flutter/lib/utils/notifier.dart @@ -0,0 +1,70 @@ +import 'dart:io'; + +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:simplecloudnotifier/settings/app_settings.dart'; +import 'package:simplecloudnotifier/state/application_log.dart'; +import 'package:simplecloudnotifier/state/globals.dart'; + +class Notifier { + static void showLocalNotification(String channelID, String channelName, String channelDescr, String title, String body, DateTime? timestamp) async { + final nid = Globals().sharedPrefs.getInt('notifier.nextid') ?? 1000; + Globals().sharedPrefs.setInt('notifier.nextid', nid + 7); + + final existingSummaryNID = Globals().sharedPrefs.getInt('notifier.summary.$channelID'); + + final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + + if (Platform.isAndroid && AppSettings().groupNotifications) { + final activeNotifications = (await flutterLocalNotificationsPlugin.getActiveNotifications()).where((p) => p.groupKey == channelID).toList(); + final summaryNotification = activeNotifications.where((p) => p.id == existingSummaryNID).toList(); + + ApplicationLog.debug('found ${activeNotifications.length} active notifications in this group (${summaryNotification.length} summary notifications for channel ${channelID} with nid [${existingSummaryNID}])'); + + if (activeNotifications.isNotEmpty && !activeNotifications.any((p) => p.id == existingSummaryNID)) { + // ======== SHOW SUMMARY/GROUPING NOTIFICATION ======== + final newSummaryNID = nid + 1; + ApplicationLog.debug('Create new summary notifications for channel ${channelID} with nid [${newSummaryNID}])'); + Globals().sharedPrefs.setInt('notifier.summary.$channelID', newSummaryNID); + await flutterLocalNotificationsPlugin.show( + newSummaryNID, + channelName, + "(multiple notifications)", + NotificationDetails( + android: AndroidNotificationDetails( + channelID, + channelName, + importance: Importance.max, + priority: Priority.high, + groupKey: channelID, + setAsGroupSummary: true, + subText: (channelName == 'main') ? null : channelName, + ), + ), + ); + } + } + + final newMessageNID = nid + 2; + + ApplicationLog.debug('Create new local notifications for message in channel ${channelID} with nid [${newMessageNID}])'); + + // ======== SHOW NOTIFICATION ======== + await flutterLocalNotificationsPlugin.show( + newMessageNID, + title, + body, + NotificationDetails( + android: AndroidNotificationDetails( + channelID, + channelName, + channelDescription: channelDescr, + importance: Importance.max, + priority: Priority.high, + when: timestamp?.millisecondsSinceEpoch, + groupKey: channelID, + subText: (channelName == 'main') ? null : channelName, + ), + ), + ); + } +} diff --git a/flutter/macos/Flutter/GeneratedPluginRegistrant.swift b/flutter/macos/Flutter/GeneratedPluginRegistrant.swift index d39b825..04d1315 100644 --- a/flutter/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/flutter/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,6 +8,7 @@ import Foundation import device_info_plus import firebase_core import firebase_messaging +import flutter_local_notifications import package_info_plus import path_provider_foundation import share_plus @@ -18,6 +19,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 0e0c132..d410647 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -209,6 +209,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.6" + dbus: + dependency: transitive + description: + name: dbus + sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" + url: "https://pub.dev" + source: hosted + version: "0.7.10" device_info_plus: dependency: "direct main" description: @@ -342,6 +350,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "40e6fbd2da7dcc7ed78432c5cdab1559674b4af035fddbfb2f9a8f9c2112fcef" + url: "https://pub.dev" + source: hosted + version: "17.1.2" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "33f741ef47b5f63cc7f78fe75eeeac7e19f171ff3c3df054d84c1e38bedb6a03" + url: "https://pub.dev" + source: hosted + version: "4.0.0+1" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "340abf67df238f7f0ef58f4a26d2a83e1ab74c77ab03cd2b2d5018ac64db30b7" + url: "https://pub.dev" + source: hosted + version: "7.1.0" flutter_staggered_grid_view: dependency: transitive description: @@ -916,6 +948,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.1" + timezone: + dependency: transitive + description: + name: timezone + sha256: a6ccda4a69a442098b602c44e61a1e2b4bf6f5516e875bbf0f427d5df14745d5 + url: "https://pub.dev" + source: hosted + version: "0.9.3" timing: dependency: transitive description: diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index e3df6d1..15b46bd 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -33,6 +33,7 @@ dependencies: toastification: ^2.0.0 uuid: ^4.4.0 share_plus: ^9.0.0 + flutter_local_notifications: ^17.1.2 dependency_overrides: From 0bbe5fc7fa1d3055b4e7d4e69484529dbf420a6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Sun, 16 Jun 2024 00:46:46 +0200 Subject: [PATCH 02/16] Working on message search+filter --- flutter/lib/components/layout/app_bar.dart | 155 ++++++++++++++---- .../layout/app_bar_filter_dialog.dart | 86 ++++++++++ .../layout/app_bar_progress_indicator.dart | 20 +-- .../message_list/message_filter_chiplet.dart | 36 ++++ .../lib/pages/message_list/message_list.dart | 68 ++++++-- .../lib/pages/message_view/message_view.dart | 14 ++ flutter/lib/state/app_bar_state.dart | 25 +++ flutter/lib/state/fb_message.dart | 4 +- flutter/lib/state/globals.dart | 1 - flutter/lib/utils/navi.dart | 9 + 10 files changed, 354 insertions(+), 64 deletions(-) create mode 100644 flutter/lib/components/layout/app_bar_filter_dialog.dart create mode 100644 flutter/lib/pages/message_list/message_filter_chiplet.dart diff --git a/flutter/lib/components/layout/app_bar.dart b/flutter/lib/components/layout/app_bar.dart index c0e63ed..d9755b0 100644 --- a/flutter/lib/components/layout/app_bar.dart +++ b/flutter/lib/components/layout/app_bar.dart @@ -1,14 +1,18 @@ 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_filter_dialog.dart'; import 'package:simplecloudnotifier/components/layout/app_bar_progress_indicator.dart'; import 'package:simplecloudnotifier/pages/debug/debug_main.dart'; import 'package:simplecloudnotifier/settings/app_settings.dart'; +import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/app_theme.dart'; import 'package:simplecloudnotifier/utils/navi.dart'; +import 'package:simplecloudnotifier/utils/toaster.dart'; -class SCNAppBar extends StatelessWidget implements PreferredSizeWidget { - const SCNAppBar({ +class SCNAppBar extends StatefulWidget implements PreferredSizeWidget { + SCNAppBar({ Key? key, required this.title, required this.showThemeSwitch, @@ -23,6 +27,22 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget { final bool showShare; final void Function()? onShare; + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); + + @override + State createState() => _SCNAppBarState(); +} + +class _SCNAppBarState extends State { + final TextEditingController _ctrlSearchField = TextEditingController(); + + @override + void dispose() { + _ctrlSearchField.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final cfg = Provider.of(context); @@ -39,7 +59,7 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget { )); } - if (showThemeSwitch) { + if (widget.showThemeSwitch) { actions.add(Consumer( builder: (context, appTheme, child) => IconButton( icon: Icon(appTheme.darkMode ? FontAwesomeIcons.solidSun : FontAwesomeIcons.solidMoon), @@ -48,54 +68,117 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget { ), )); } else { - actions.add(Visibility( - visible: false, - maintainSize: true, - maintainAnimation: true, - maintainState: true, - child: IconButton( - icon: const Icon(FontAwesomeIcons.square), - onPressed: () {/*TODO*/}, - ), - )); + actions.add(_buildSpacer()); } - if (showSearch) { + if (widget.showSearch) { + actions.add(IconButton( + icon: const Icon(FontAwesomeIcons.solidFilter), + tooltip: 'Filter', + onPressed: () => _showFilterDialog(context), + )); actions.add(IconButton( icon: const Icon(FontAwesomeIcons.solidMagnifyingGlass), tooltip: 'Search', - onPressed: () {/*TODO*/}, + onPressed: () => AppBarState().setShowSearchField(true), )); - } else if (showShare) { + } else if (widget.showShare) { + actions.add(_buildSpacer()); actions.add(IconButton( icon: const Icon(FontAwesomeIcons.solidShareNodes), tooltip: 'Share', - onPressed: onShare ?? () {}, + onPressed: widget.onShare ?? () {}, )); } else { - actions.add(Visibility( - visible: false, - maintainSize: true, - maintainAnimation: true, - maintainState: true, - child: IconButton( - icon: const Icon(FontAwesomeIcons.square), - onPressed: () {/*TODO*/}, - ), - )); + actions.add(_buildSpacer()); } - 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(), + return Consumer(builder: (context, value, child) { + if (value.showSearchField) { + return AppBar( + leading: IconButton( + icon: const Icon(FontAwesomeIcons.solidArrowLeft), + onPressed: () { + value.setShowSearchField(false); + }, + ), + title: _buildSearchTextField(context), + actions: [ + IconButton( + icon: const Icon(FontAwesomeIcons.solidMagnifyingGlass), + onPressed: () { + value.setShowSearchField(false); + AppBarState().notifySearchListeners(_ctrlSearchField.text); + _ctrlSearchField.clear(); + }, + ), + ], + backgroundColor: Theme.of(context).secondaryHeaderColor, + bottom: PreferredSize( + preferredSize: Size(double.infinity, 1.0), + child: AppBarProgressIndicator(show: value.loadingIndeterminate), + ), + ); + } else { + return AppBar( + title: Text(widget.title ?? 'SCN'), + actions: actions, + backgroundColor: Theme.of(context).secondaryHeaderColor, + bottom: PreferredSize( + preferredSize: Size(double.infinity, 1.0), + child: AppBarProgressIndicator(show: value.loadingIndeterminate), + ), + ); + } + }); + } + + Visibility _buildSpacer() { + return Visibility( + visible: false, + maintainSize: true, + maintainAnimation: true, + maintainState: true, + child: IconButton( + icon: const Icon(FontAwesomeIcons.square), + onPressed: () {/* NO-OP */}, ), ); } - @override - Size get preferredSize => const Size.fromHeight(kToolbarHeight); + Widget _buildSearchTextField(BuildContext context) { + return TextField( + controller: _ctrlSearchField, + autofocus: true, + style: TextStyle(fontSize: 20), + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: 'Search', + ), + onSubmitted: (value) { + AppBarState().setShowSearchField(false); + AppBarState().notifySearchListeners(_ctrlSearchField.text); + _ctrlSearchField.clear(); + }, + ); + } + + void _showFilterDialog(BuildContext context) { + double vpWidth = MediaQuery.sizeOf(context).width; + + showDialog( + context: context, + barrierDismissible: true, + barrierColor: Colors.transparent, + builder: (BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(0)), + alignment: Alignment.topCenter, + insetPadding: EdgeInsets.fromLTRB(0, this.widget.preferredSize.height, 0, 0), + backgroundColor: Colors.transparent, + child: AppBarFilterDialog(), + ); + }, + ); + } } diff --git a/flutter/lib/components/layout/app_bar_filter_dialog.dart b/flutter/lib/components/layout/app_bar_filter_dialog.dart new file mode 100644 index 0000000..a201801 --- /dev/null +++ b/flutter/lib/components/layout/app_bar_filter_dialog.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:simplecloudnotifier/utils/navi.dart'; + +class AppBarFilterDialog extends StatefulWidget { + @override + _AppBarFilterDialogState createState() => _AppBarFilterDialogState(); +} + +class _AppBarFilterDialogState extends State { + double _height = 0; + + double _targetHeight = 4 + (48 * 6) + (16 * 5) + 4; + + @override + void initState() { + super.initState(); + Future.delayed(Duration.zero, () { + setState(() { + _height = _targetHeight; + }); + }); + } + + @override + Widget build(BuildContext context) { + double vpWidth = MediaQuery.sizeOf(context).width; + + return Container( + margin: const EdgeInsets.all(0), + width: vpWidth, + color: Colors.transparent, + child: Column( + children: [ + Container( + color: Theme.of(context).secondaryHeaderColor, + child: AnimatedContainer( + duration: Duration(milliseconds: 350), + curve: Curves.easeInCubic, + height: _height, + child: ClipRect( + child: OverflowBox( + alignment: Alignment.topCenter, + maxWidth: vpWidth, + minWidth: vpWidth, + minHeight: 0, + maxHeight: _targetHeight, + child: Column( + children: [ + SizedBox(height: 4), + _buildFilterItem(context, FontAwesomeIcons.magnifyingGlass, 'Search'), + Divider(), + _buildFilterItem(context, FontAwesomeIcons.snake, 'Channel'), + Divider(), + _buildFilterItem(context, FontAwesomeIcons.signature, 'Sender'), + Divider(), + _buildFilterItem(context, FontAwesomeIcons.timer, 'Time'), + Divider(), + _buildFilterItem(context, FontAwesomeIcons.bolt, 'Priority'), + Divider(), + _buildFilterItem(context, FontAwesomeIcons.gearCode, 'Key'), + SizedBox(height: 4), + ], + ), + ), + ), + ), + ), + Expanded(child: GestureDetector(child: Container(width: vpWidth, color: Color(0x88000000)), onTap: () => Navi.popDialog(context))), + ], + ), + ); + } + + Widget _buildFilterItem(BuildContext context, IconData icon, String label) { + return ListTile( + visualDensity: VisualDensity.compact, + title: Text(label), + leading: Icon(icon), + onTap: () { + Navi.popDialog(context); + //TOOD show more... + }, + ); + } +} diff --git a/flutter/lib/components/layout/app_bar_progress_indicator.dart b/flutter/lib/components/layout/app_bar_progress_indicator.dart index c227ea7..9eb3e89 100644 --- a/flutter/lib/components/layout/app_bar_progress_indicator.dart +++ b/flutter/lib/components/layout/app_bar_progress_indicator.dart @@ -1,21 +1,19 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:simplecloudnotifier/state/app_bar_state.dart'; class AppBarProgressIndicator extends StatelessWidget implements PreferredSizeWidget { + AppBarProgressIndicator({required this.show}); + + final bool show; + @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 - } - }, - ); + if (show) { + return LinearProgressIndicator(value: null); + } else { + return SizedBox.square(dimension: 4); // 4 height is the same as the LinearProgressIndicator + } } } diff --git a/flutter/lib/pages/message_list/message_filter_chiplet.dart b/flutter/lib/pages/message_list/message_filter_chiplet.dart new file mode 100644 index 0000000..86f99bd --- /dev/null +++ b/flutter/lib/pages/message_list/message_filter_chiplet.dart @@ -0,0 +1,36 @@ +import 'package:flutter/src/widgets/icon_data.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; + +enum MessageFilterChipletType { + search, + channel, + sender, + timeRange, + priority, + sendkey, +} + +class MessageFilterChiplet { + final String label; + final String value; + final MessageFilterChipletType type; + + MessageFilterChiplet({required this.label, required this.value, required this.type}); + + IconData? icon() { + switch (type) { + case MessageFilterChipletType.search: + return FontAwesomeIcons.magnifyingGlass; + case MessageFilterChipletType.channel: + return FontAwesomeIcons.snake; + case MessageFilterChipletType.sender: + return FontAwesomeIcons.signature; + case MessageFilterChipletType.timeRange: + return FontAwesomeIcons.timer; + case MessageFilterChipletType.priority: + return FontAwesomeIcons.bolt; + case MessageFilterChipletType.sendkey: + return FontAwesomeIcons.gearCode; + } + } +} diff --git a/flutter/lib/pages/message_list/message_list.dart b/flutter/lib/pages/message_list/message_list.dart index fe094dc..b9cc8b7 100644 --- a/flutter/lib/pages/message_list/message_list.dart +++ b/flutter/lib/pages/message_list/message_list.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/scn_message.dart'; +import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart'; import 'package:simplecloudnotifier/pages/message_view/message_view.dart'; import 'package:simplecloudnotifier/settings/app_settings.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart'; @@ -34,10 +35,14 @@ class _MessageListPageState extends State with RouteAware { bool _isInitialized = false; + List _filterChiplets = []; + @override void initState() { super.initState(); + AppBarState().subscribeSearchListener(_onAppBarSearch); + _pagingController.addPageRequestListener(_fetchPage); if (widget.isVisiblePage && !_isInitialized) _realInitState(); @@ -94,6 +99,7 @@ class _MessageListPageState extends State with RouteAware { @override void dispose() { ApplicationLog.debug('MessageListPage::dispose'); + AppBarState().unsubscribeSearchListener(_onAppBarSearch); Navi.modalRouteObserver.unsubscribe(this); _pagingController.dispose(); _lifecyleListener.dispose(); @@ -222,22 +228,50 @@ class _MessageListPageState extends State with RouteAware { Widget build(BuildContext context) { return Padding( padding: EdgeInsets.fromLTRB(8, 4, 8, 4), - child: RefreshIndicator( - onRefresh: () => Future.sync( - () => _pagingController.refresh(), - ), - child: PagedListView( - pagingController: _pagingController, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) => MessageListItem( - message: item, - allChannels: _channels ?? {}, - onPressed: () { - Navi.push(context, () => MessageViewPage(message: item)); - }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_filterChiplets.isNotEmpty) + Wrap( + alignment: WrapAlignment.start, + spacing: 5.0, + children: [ + for (var chiplet in _filterChiplets) _buildFilterChip(context, chiplet), + ], + ), + Expanded( + child: RefreshIndicator( + onRefresh: () => Future.sync( + () => _pagingController.refresh(), + ), + child: PagedListView( + pagingController: _pagingController, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => MessageListItem( + message: item, + allChannels: _channels ?? {}, + onPressed: () { + Navi.push(context, () => MessageViewPage(message: item)); + }, + ), + ), + ), ), ), - ), + ], + ), + ); + } + + Widget _buildFilterChip(BuildContext context, MessageFilterChiplet chiplet) { + return Padding( + padding: const EdgeInsets.fromLTRB(0, 2, 0, 2), + child: InputChip( + avatar: Icon(chiplet.icon()), + label: Text(chiplet.label), + onDeleted: () => setState(() => _filterChiplets.remove(chiplet)), + onPressed: () {/* TODO idk what to do here ? */}, + visualDensity: VisualDensity(horizontal: -4, vertical: -4), ), ); } @@ -269,4 +303,10 @@ class _MessageListPageState extends State with RouteAware { await cache.delete(val.messageID); } } + + void _onAppBarSearch(String str) { + setState(() { + _filterChiplets = _filterChiplets.where((element) => false).toList() + [MessageFilterChiplet(label: str, value: str, type: MessageFilterChipletType.search)]; + }); + } } diff --git a/flutter/lib/pages/message_view/message_view.dart b/flutter/lib/pages/message_view/message_view.dart index 29c51c9..d49ccc5 100644 --- a/flutter/lib/pages/message_view/message_view.dart +++ b/flutter/lib/pages/message_view/message_view.dart @@ -137,6 +137,7 @@ class _MessageViewPageState extends State { _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', [if (user != null) user.userID, if (user?.username != null) user!.username!], () => {/*TODO*/}), //TODO + _buildMetaCard(context, FontAwesomeIcons.solidBolt, 'Priority', [_prettyPrintPriority(message.priority)], () => {/*TODO*/}), //TODO if (message.senderUserID == userAccUserID) UI.button(text: "Delete Message", onPressed: () {/*TODO*/}, color: Colors.red[900]), ], ), @@ -252,4 +253,17 @@ class _MessageViewPageState extends State { String _preformatTitle(SCNMessage message) { return message.title.replaceAll('\n', '').replaceAll('\r', '').replaceAll('\t', ' '); } + + String _prettyPrintPriority(int priority) { + switch (priority) { + case 0: + return 'Low (0)'; + case 1: + return 'Normal (1)'; + case 2: + return 'High (2)'; + default: + return 'Unknown ($priority)'; + } + } } diff --git a/flutter/lib/state/app_bar_state.dart b/flutter/lib/state/app_bar_state.dart index b62384a..0aeac53 100644 --- a/flutter/lib/state/app_bar_state.dart +++ b/flutter/lib/state/app_bar_state.dart @@ -9,12 +9,37 @@ class AppBarState extends ChangeNotifier { AppBarState._internal() {} + List _searchListeners = []; + bool _loadingIndeterminate = false; bool get loadingIndeterminate => _loadingIndeterminate; + bool _showSearchField = false; + bool get showSearchField => _showSearchField; + void setLoadingIndeterminate(bool v) { if (_loadingIndeterminate == v) return; _loadingIndeterminate = v; notifyListeners(); } + + void setShowSearchField(bool v) { + if (_showSearchField == v) return; + _showSearchField = v; + notifyListeners(); + } + + void subscribeSearchListener(void Function(String) listener) { + _searchListeners.add(listener); + } + + void unsubscribeSearchListener(void Function(String) listener) { + _searchListeners.remove(listener); + } + + void notifySearchListeners(String query) { + for (var listener in _searchListeners) { + listener(query); + } + } } diff --git a/flutter/lib/state/fb_message.dart b/flutter/lib/state/fb_message.dart index bd9782b..ea913c1 100644 --- a/flutter/lib/state/fb_message.dart +++ b/flutter/lib/state/fb_message.dart @@ -158,11 +158,11 @@ class FBMessage extends HiveObject implements FieldDebuggable { this.notificationAndroidCount = rmsg.notification?.android?.count, this.notificationAndroidImageUrl = rmsg.notification?.android?.imageUrl, this.notificationAndroidLink = rmsg.notification?.android?.link, - this.notificationAndroidPriority = rmsg.notification?.android?.priority?.toString(), + this.notificationAndroidPriority = rmsg.notification?.android?.priority.toString(), this.notificationAndroidSmallIcon = rmsg.notification?.android?.smallIcon, this.notificationAndroidSound = rmsg.notification?.android?.sound, this.notificationAndroidTicker = rmsg.notification?.android?.ticker, - this.notificationAndroidVisibility = rmsg.notification?.android?.visibility?.toString(), + this.notificationAndroidVisibility = rmsg.notification?.android?.visibility.toString(), this.notificationAndroidTag = rmsg.notification?.android?.tag, this.notificationAppleBadge = rmsg.notification?.apple?.badge, this.notificationAppleSound = rmsg.notification?.apple?.sound?.toString(), diff --git a/flutter/lib/state/globals.dart b/flutter/lib/state/globals.dart index d54fa56..004fd2a 100644 --- a/flutter/lib/state/globals.dart +++ b/flutter/lib/state/globals.dart @@ -1,4 +1,3 @@ -import 'dart:ffi'; import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; diff --git a/flutter/lib/utils/navi.dart b/flutter/lib/utils/navi.dart index 61d9d93..c3e6510 100644 --- a/flutter/lib/utils/navi.dart +++ b/flutter/lib/utils/navi.dart @@ -8,15 +8,21 @@ class Navi { static void push(BuildContext context, T Function() builder) { Provider.of(context, listen: false).setLoadingIndeterminate(false); + Provider.of(context, listen: false).setShowSearchField(false); Navigator.push(context, MaterialPageRoute(builder: (context) => builder())); } static void popToRoot(BuildContext context) { Provider.of(context, listen: false).setLoadingIndeterminate(false); + Provider.of(context, listen: false).setShowSearchField(false); Navigator.popUntil(context, (route) => route.isFirst); } + + static void popDialog(BuildContext dialogContext) { + Navigator.pop(dialogContext); + } } class SCNRouteObserver extends RouteObserver> { @@ -25,6 +31,7 @@ class SCNRouteObserver extends RouteObserver> { super.didPush(route, previousRoute); if (route is PageRoute) { AppBarState().setLoadingIndeterminate(false); + AppBarState().setShowSearchField(false); print('[SCNRouteObserver] .didPush()'); } @@ -35,6 +42,7 @@ class SCNRouteObserver extends RouteObserver> { super.didReplace(newRoute: newRoute, oldRoute: oldRoute); if (newRoute is PageRoute) { AppBarState().setLoadingIndeterminate(false); + AppBarState().setShowSearchField(false); print('[SCNRouteObserver] .didReplace()'); } @@ -45,6 +53,7 @@ class SCNRouteObserver extends RouteObserver> { super.didPop(route, previousRoute); if (previousRoute is PageRoute && route is PageRoute) { AppBarState().setLoadingIndeterminate(false); + AppBarState().setShowSearchField(false); print('[SCNRouteObserver] .didPop()'); } From c8bc7665f7d915bb40ebd6ae0da4c5c2e62d1a5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Mon, 17 Jun 2024 22:26:48 +0200 Subject: [PATCH 03/16] Fix background messages in release-build --- flutter/lib/main.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index 6d540c2..4e1d628 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -265,10 +265,12 @@ void setFirebaseToken(String fcmToken) async { } } +@pragma('vm:entry-point') Future _onBackgroundMessage(RemoteMessage message) async { await _receiveMessage(message, false); } +@pragma('vm:entry-point') void _onForegroundMessage(RemoteMessage message) { _receiveMessage(message, true); } From 5b8a1e86e0977245b4ee3ab6f36717fabdc14a16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Mon, 17 Jun 2024 22:53:03 +0200 Subject: [PATCH 04/16] Save user+client in Prefs and only background-fetch them on startup --- flutter/lib/main.dart | 27 +++++------ flutter/lib/models/client.dart | 13 +++++ flutter/lib/models/user.dart | 23 +++++++++ flutter/lib/state/app_auth.dart | 86 +++++++++++++++++++++++++++------ 4 files changed, 121 insertions(+), 28 deletions(-) diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index 4e1d628..c2ec8f3 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -101,20 +101,19 @@ void main() async { final appAuth = AppAuth(); // ensure UserAccount is loaded if (appAuth.isAuth()) { - try { - print('[INIT] Load User...'); - await appAuth.loadUser(); - //TODO fallback to cached user (perhaps event use cached client (if exists) directly and only update/load in background) - } catch (exc, trace) { - ApplicationLog.error('Failed to load user (on startup): ' + exc.toString(), trace: trace); - } - try { - print('[INIT] Load Client...'); - await appAuth.loadClient(); - //TODO fallback to cached client (perhaps event use cached client (if exists) directly and only update/load in background) - } catch (exc, trace) { - ApplicationLog.error('Failed to load user (on startup): ' + exc.toString(), trace: trace); - } + // load user+client in background + () async { + try { + await appAuth.loadUser(); + } catch (exc, trace) { + ApplicationLog.error('Failed to load user (background load on startup): ' + exc.toString(), trace: trace); + } + try { + await appAuth.loadClient(); + } catch (exc, trace) { + ApplicationLog.error('Failed to load user (background load on startup): ' + exc.toString(), trace: trace); + } + }(); } if (!Platform.isLinux) { diff --git a/flutter/lib/models/client.dart b/flutter/lib/models/client.dart index 3c4d778..cdd8681 100644 --- a/flutter/lib/models/client.dart +++ b/flutter/lib/models/client.dart @@ -32,6 +32,19 @@ class Client { ); } + Map toJson() { + return { + 'client_id': clientID, + 'user_id': userID, + 'type': type, + 'fcm_token': fcmToken, + 'timestamp_created': timestampCreated, + 'agent_model': agentModel, + 'agent_version': agentVersion, + 'name': name, + }; + } + static List fromJsonArray(List jsonArr) { return jsonArr.map((e) => Client.fromJson(e as Map)).toList(); } diff --git a/flutter/lib/models/user.dart b/flutter/lib/models/user.dart index 58bd769..b7f9cfa 100644 --- a/flutter/lib/models/user.dart +++ b/flutter/lib/models/user.dart @@ -63,6 +63,29 @@ class User { maxUserMessageIDLength: json['max_user_message_id_length'] as int, ); } + + Map toJson() { + return { + 'user_id': userID, + 'username': username, + 'timestamp_created': timestampCreated, + 'timestamp_lastread': timestampLastRead, + 'timestamp_lastsent': timestampLastSent, + 'messages_sent': messagesSent, + 'quota_used': quotaUsed, + 'quota_remaining': quotaRemaining, + 'quota_max': quotaPerDay, + 'is_pro': isPro, + 'default_channel': defaultChannel, + 'max_body_size': maxBodySize, + 'max_title_length': maxTitleLength, + 'default_priority': defaultPriority, + 'max_channel_name_length': maxChannelNameLength, + 'max_channel_description_length': maxChannelDescriptionLength, + 'max_sender_name_length': maxSenderNameLength, + 'max_user_message_id_length': maxUserMessageIDLength, + }; + } } class UserWithClientsAndKeys { diff --git a/flutter/lib/state/app_auth.dart b/flutter/lib/state/app_auth.dart index a161485..7524f94 100644 --- a/flutter/lib/state/app_auth.dart +++ b/flutter/lib/state/app_auth.dart @@ -1,8 +1,11 @@ +import 'dart:convert'; + import 'package:flutter/foundation.dart'; import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/api/api_exception.dart'; import 'package:simplecloudnotifier/models/client.dart'; import 'package:simplecloudnotifier/models/user.dart'; +import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/globals.dart'; import 'package:simplecloudnotifier/state/token_source.dart'; @@ -12,9 +15,9 @@ class AppAuth extends ChangeNotifier implements TokenSource { String? _tokenAdmin; String? _tokenSend; - User? _user; - Client? _client; - DateTime? _clientQueryTime; + (User, DateTime)? _user; + + (Client, DateTime)? _client; String? get userID => _userID; String? get tokenAdmin => _tokenAdmin; @@ -35,17 +38,21 @@ class AppAuth extends ChangeNotifier implements TokenSource { } void set(User user, Client client, String tokenAdmin, String tokenSend) { - _client = client; - _user = user; + _client = (client, DateTime.now()); + + _user = (user, DateTime.now()); + _userID = user.userID; _clientID = client.clientID; + _tokenAdmin = tokenAdmin; _tokenSend = tokenSend; + notifyListeners(); } void setClientAndClientID(Client client) { - _client = client; + _client = (client, DateTime.now()); _clientID = client.clientID; notifyListeners(); } @@ -83,6 +90,33 @@ class AppAuth extends ChangeNotifier implements TokenSource { _client = null; _user = null; + final userjson = Globals().sharedPrefs.getString('auth.user.obj'); + final userqdate = Globals().sharedPrefs.getString('auth.user.qdate'); + final clientjson = Globals().sharedPrefs.getString('auth.client.obj'); + final clientqdate = Globals().sharedPrefs.getString('auth.client.qdate'); + + if (userjson != null && userqdate != null) { + try { + final ts = DateTime.parse(userqdate); + final obj = User.fromJson(jsonDecode(userjson) as Map); + _user = (obj, ts); + } catch (exc, trace) { + ApplicationLog.error('failed to parse user object from shared-prefs (auth.user.obj): ' + exc.toString(), additional: 'Data:\n${userjson}\nQDate:\n${userqdate}', trace: trace); + _user = null; + } + } + + if (clientjson != null && clientqdate != null) { + try { + final ts = DateTime.parse(clientqdate); + final obj = Client.fromJson(jsonDecode(clientjson) as Map); + _client = (obj, ts); + } catch (exc, trace) { + ApplicationLog.error('failed to parse user object from shared-prefs (auth.client.obj): ' + exc.toString(), additional: 'Data:\n${clientjson}\nQDate:\n${clientqdate}', trace: trace); + _client = null; + } + } + notifyListeners(); } @@ -94,6 +128,10 @@ class AppAuth extends ChangeNotifier implements TokenSource { await Globals().sharedPrefs.remove('auth.tokensend'); await Globals().sharedPrefs.setString('auth.cdate', ""); await Globals().sharedPrefs.setString('auth.mdate', DateTime.now().toIso8601String()); + await Globals().sharedPrefs.remove('auth.user.obj'); + await Globals().sharedPrefs.remove('auth.user.qdate'); + await Globals().sharedPrefs.remove('auth.client.obj'); + await Globals().sharedPrefs.remove('auth.client.qdate'); } else { await Globals().sharedPrefs.setString('auth.userid', _userID!); await Globals().sharedPrefs.setString('auth.clientid', _clientID!); @@ -101,14 +139,34 @@ class AppAuth extends ChangeNotifier implements TokenSource { await Globals().sharedPrefs.setString('auth.tokensend', _tokenSend!); if (Globals().sharedPrefs.getString('auth.cdate') == null) await Globals().sharedPrefs.setString('auth.cdate', DateTime.now().toIso8601String()); await Globals().sharedPrefs.setString('auth.mdate', DateTime.now().toIso8601String()); + + if (_user != null) { + await Globals().sharedPrefs.setString('auth.user.obj', jsonEncode(_user!.$1.toJson())); + await Globals().sharedPrefs.setString('auth.user.qdate', _user!.$2.toIso8601String()); + } else { + await Globals().sharedPrefs.remove('auth.user.obj'); + await Globals().sharedPrefs.remove('auth.user.qdate'); + } + + if (_client != null) { + await Globals().sharedPrefs.setString('auth.client.obj', jsonEncode(_client!.$1.toJson())); + await Globals().sharedPrefs.setString('auth.client.qdate', _client!.$2.toIso8601String()); + } else { + await Globals().sharedPrefs.remove('auth.client.obj'); + await Globals().sharedPrefs.remove('auth.client.qdate'); + } } Globals().sharedPrefs.setString('auth.mdate', DateTime.now().toIso8601String()); } - Future loadUser({bool force = false}) async { - if (!force && _user != null && _user!.userID == _userID) { - return _user!; + Future loadUser({bool force = false, Duration? forceIfOlder = null}) async { + if (forceIfOlder != null && _user != null && _user!.$2.difference(DateTime.now()) > forceIfOlder) { + force = true; + } + + if (!force && _user != null && _user!.$1.userID == _userID) { + return _user!.$1; } if (_userID == null || _tokenAdmin == null) { @@ -117,7 +175,7 @@ class AppAuth extends ChangeNotifier implements TokenSource { final user = await APIClient.getUser(this, _userID!); - _user = user; + _user = (user, DateTime.now()); await save(); @@ -125,12 +183,12 @@ class AppAuth extends ChangeNotifier implements TokenSource { } Future loadClient({bool force = false, Duration? forceIfOlder = null}) async { - if (forceIfOlder != null && _clientQueryTime != null && _clientQueryTime!.difference(DateTime.now()) > forceIfOlder) { + if (forceIfOlder != null && _client != null && _client!.$2.difference(DateTime.now()) > forceIfOlder) { force = true; } - if (!force && _client != null && _client!.clientID == _clientID) { - return _client!; + if (!force && _client != null && _client!.$1.clientID == _clientID) { + return _client!.$1; } if (_clientID == null || _tokenAdmin == null) { @@ -140,7 +198,7 @@ class AppAuth extends ChangeNotifier implements TokenSource { try { final client = await APIClient.getClient(this, _clientID!); - _client = client; + _client = (client, DateTime.now()); await save(); From 600f3365f62a56be7d1dc27df873ea0d1ba58cd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Mon, 17 Jun 2024 22:54:45 +0200 Subject: [PATCH 05/16] Disabled didPopNext() refresh of message_list --- flutter/lib/pages/message_list/message_list.dart | 6 ++++-- flutter/lib/settings/app_settings.dart | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/flutter/lib/pages/message_list/message_list.dart b/flutter/lib/pages/message_list/message_list.dart index b9cc8b7..c0f49a0 100644 --- a/flutter/lib/pages/message_list/message_list.dart +++ b/flutter/lib/pages/message_list/message_list.dart @@ -113,8 +113,10 @@ class _MessageListPageState extends State with RouteAware { @override void didPopNext() { - ApplicationLog.debug('[MessageList::RouteObserver] --> didPopNext (will background-refresh)'); - _backgroundRefresh(false); + if (AppSettings().alwaysBackgroundRefreshMessageListOnPop) { + ApplicationLog.debug('[MessageList::RouteObserver] --> didPopNext (will background-refresh)'); + _backgroundRefresh(false); + } } void _onLifecycleResume() { diff --git a/flutter/lib/settings/app_settings.dart b/flutter/lib/settings/app_settings.dart index a487fed..76df7ed 100644 --- a/flutter/lib/settings/app_settings.dart +++ b/flutter/lib/settings/app_settings.dart @@ -4,6 +4,7 @@ class AppSettings extends ChangeNotifier { bool groupNotifications = true; int messagePageSize = 128; bool showDebugButton = true; + bool alwaysBackgroundRefreshMessageListOnPop = false; static AppSettings? _singleton = AppSettings._internal(); From 59d28d3c4986750b590bb046071f0b92852da078 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Mon, 17 Jun 2024 23:23:35 +0200 Subject: [PATCH 06/16] auto-refresh message-list on FB message receive --- flutter/lib/components/layout/app_bar.dart | 9 +-- flutter/lib/main.dart | 35 ++++------- .../lib/pages/message_list/message_list.dart | 62 ++++++------------- flutter/lib/state/app_bar_state.dart | 16 ----- flutter/lib/state/app_events.dart | 47 ++++++++++++++ flutter/lib/state/scn_data_cache.dart | 60 ++++++++++++++++++ 6 files changed, 142 insertions(+), 87 deletions(-) create mode 100644 flutter/lib/state/app_events.dart create mode 100644 flutter/lib/state/scn_data_cache.dart diff --git a/flutter/lib/components/layout/app_bar.dart b/flutter/lib/components/layout/app_bar.dart index d9755b0..64f59c0 100644 --- a/flutter/lib/components/layout/app_bar.dart +++ b/flutter/lib/components/layout/app_bar.dart @@ -1,5 +1,4 @@ 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_filter_dialog.dart'; @@ -7,9 +6,9 @@ import 'package:simplecloudnotifier/components/layout/app_bar_progress_indicator import 'package:simplecloudnotifier/pages/debug/debug_main.dart'; import 'package:simplecloudnotifier/settings/app_settings.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart'; +import 'package:simplecloudnotifier/state/app_events.dart'; import 'package:simplecloudnotifier/state/app_theme.dart'; import 'package:simplecloudnotifier/utils/navi.dart'; -import 'package:simplecloudnotifier/utils/toaster.dart'; class SCNAppBar extends StatefulWidget implements PreferredSizeWidget { SCNAppBar({ @@ -108,7 +107,7 @@ class _SCNAppBarState extends State { icon: const Icon(FontAwesomeIcons.solidMagnifyingGlass), onPressed: () { value.setShowSearchField(false); - AppBarState().notifySearchListeners(_ctrlSearchField.text); + AppEvents().notifySearchListeners(_ctrlSearchField.text); _ctrlSearchField.clear(); }, ), @@ -157,15 +156,13 @@ class _SCNAppBarState extends State { ), onSubmitted: (value) { AppBarState().setShowSearchField(false); - AppBarState().notifySearchListeners(_ctrlSearchField.text); + AppEvents().notifySearchListeners(_ctrlSearchField.text); _ctrlSearchField.clear(); }, ); } void _showFilterDialog(BuildContext context) { - double vpWidth = MediaQuery.sizeOf(context).width; - showDialog( context: context, barrierDismissible: true, diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index c2ec8f3..67e9dda 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -12,6 +12,7 @@ import 'package:simplecloudnotifier/models/scn_message.dart'; import 'package:simplecloudnotifier/nav_layout.dart'; import 'package:simplecloudnotifier/settings/app_settings.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart'; +import 'package:simplecloudnotifier/state/app_events.dart'; import 'package:simplecloudnotifier/state/app_theme.dart'; import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/fb_message.dart'; @@ -19,6 +20,7 @@ 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/state/scn_data_cache.dart'; import 'package:simplecloudnotifier/utils/navi.dart'; import 'package:simplecloudnotifier/utils/notifier.dart'; import 'package:toastification/toastification.dart'; @@ -308,35 +310,18 @@ Future _receiveMessage(RemoteMessage message, bool foreground) async { ApplicationLog.info('Received FB message (${foreground ? 'foreground' : 'background'}): ${message.messageId ?? 'NULL'}'); - SCNMessage? receivedMessage; + String scn_msg_id; try { - final scn_msg_id = message.data['scn_msg_id'] as String; - final usr_msg_id = message.data['usr_msg_id'] as String; + scn_msg_id = message.data['scn_msg_id'] as String; + final timestamp = DateTime.fromMillisecondsSinceEpoch(int.parse(message.data['timestamp'] as String) * 1000); - final priority = int.parse(message.data['priority'] as String); final title = message.data['title'] as String; final channel = message.data['channel'] as String; final channel_id = message.data['channel_id'] as String; final body = message.data['body'] as String; Notifier.showLocalNotification(channel_id, channel, 'Channel: ${channel}', title, body, timestamp); - - receivedMessage = SCNMessage( - messageID: scn_msg_id, - userMessageID: usr_msg_id, - timestamp: timestamp.toIso8601String(), - priority: priority, - trimmed: true, - title: title, - channelID: channel_id, - channelInternalName: channel, - content: body, - senderIP: '', - senderName: '', - senderUserID: '', - usedKeyID: '', - ); } catch (exc, trace) { ApplicationLog.error('Failed to decode received FB message' + exc.toString(), trace: trace); Notifier.showLocalNotification("@ERROR", "@ERROR", 'Error Channel', "Error", "Failed to receive SCN message (decode failed)", null); @@ -351,8 +336,14 @@ Future _receiveMessage(RemoteMessage message, bool foreground) async { return; } - //TODO add to scn-message-cache - //TODO refresh message_list view (if shown/initialized) + try { + final msg = await APIClient.getMessage(AppAuth(), scn_msg_id); + SCNDataCache().addToMessageCache([msg]); + if (foreground) AppEvents().notifyMessageReceivedListeners(msg); + } catch (exc, trace) { + ApplicationLog.error('Failed to query+persist message' + exc.toString(), trace: trace); + return; + } } void _receiveLocalDarwinNotification(int id, String? title, String? body, String? payload) { diff --git a/flutter/lib/pages/message_list/message_list.dart b/flutter/lib/pages/message_list/message_list.dart index c0f49a0..1277a6e 100644 --- a/flutter/lib/pages/message_list/message_list.dart +++ b/flutter/lib/pages/message_list/message_list.dart @@ -9,9 +9,11 @@ import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.da import 'package:simplecloudnotifier/pages/message_view/message_view.dart'; import 'package:simplecloudnotifier/settings/app_settings.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart'; +import 'package:simplecloudnotifier/state/app_events.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/state/scn_data_cache.dart'; import 'package:simplecloudnotifier/utils/navi.dart'; class MessageListPage extends StatefulWidget { @@ -41,7 +43,8 @@ class _MessageListPageState extends State with RouteAware { void initState() { super.initState(); - AppBarState().subscribeSearchListener(_onAppBarSearch); + AppEvents().subscribeSearchListener(_onAppBarSearch); + AppEvents().subscribeMessageReceivedListener(_onMessageReceivedViaNotification); _pagingController.addPageRequestListener(_fetchPage); @@ -68,18 +71,12 @@ class _MessageListPageState extends State with RouteAware { 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) { + if (SCNDataCache().hasMessagesAndChannels()) { // ==== Use cache values - and refresh in background - _channels = {for (var v in chnCache.values) v.channelID: v}; + _channels = SCNDataCache().getChannelMap(); - final cacheMessages = msgCache.values.toList(); - cacheMessages.sort((a, b) => -1 * a.timestamp.compareTo(b.timestamp)); - - _pagingController.value = PagingState(nextPageKey: null, itemList: cacheMessages, error: null); + _pagingController.value = PagingState(nextPageKey: null, itemList: SCNDataCache().getMessagesSorted(), error: null); _backgroundRefresh(true); } else { @@ -99,7 +96,8 @@ class _MessageListPageState extends State with RouteAware { @override void dispose() { ApplicationLog.debug('MessageListPage::dispose'); - AppBarState().unsubscribeSearchListener(_onAppBarSearch); + AppEvents().unsubscribeSearchListener(_onAppBarSearch); + AppEvents().unsubscribeMessageReceivedListener(_onMessageReceivedViaNotification); Navi.modalRouteObserver.unsubscribe(this); _pagingController.dispose(); _lifecyleListener.dispose(); @@ -140,12 +138,12 @@ class _MessageListPageState extends State with RouteAware { final channels = await APIClient.getChannelList(acc, ChannelSelector.allAny); _channels = {for (var v in channels) v.channel.channelID: v.channel}; - _setChannelCache(channels); // no await + SCNDataCache().setChannelCache(channels); // no await } final (npt, newItems) = await APIClient.getMessageList(acc, thisPageToken, pageSize: cfg.messagePageSize); - _addToMessageCache(newItems); // no await + SCNDataCache().addToMessageCache(newItems); // no await ApplicationLog.debug('Finished MessageList::_pagingController::_fetchPage [ ${newItems.length} items and npt: ${thisPageToken} --> ${npt} ]'); @@ -176,12 +174,12 @@ class _MessageListPageState extends State with RouteAware { setState(() { _channels = {for (var v in channels) v.channel.channelID: v.channel}; }); - _setChannelCache(channels); // no await + SCNDataCache().setChannelCache(channels); // no await } final (npt, newItems) = await APIClient.getMessageList(acc, '@start', pageSize: cfg.messagePageSize); - _addToMessageCache(newItems); // no await + SCNDataCache().addToMessageCache(newItems); // no await if (fullReplaceState) { // fully replace/reset state @@ -278,37 +276,15 @@ class _MessageListPageState extends State with RouteAware { ); } - 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 cfg = AppSettings(); - - 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 < cfg.messagePageSize) return; - - final allValues = cache.values.toList(); - - allValues.sort((a, b) => -1 * a.timestamp.compareTo(b.timestamp)); - - for (var val in allValues.sublist(cfg.messagePageSize)) { - await cache.delete(val.messageID); - } - } - void _onAppBarSearch(String str) { setState(() { _filterChiplets = _filterChiplets.where((element) => false).toList() + [MessageFilterChiplet(label: str, value: str, type: MessageFilterChipletType.search)]; }); } + + void _onMessageReceivedViaNotification(SCNMessage msg) { + setState(() { + _pagingController.itemList = [msg] + (_pagingController.itemList ?? []); + }); + } } diff --git a/flutter/lib/state/app_bar_state.dart b/flutter/lib/state/app_bar_state.dart index 0aeac53..e1c6a32 100644 --- a/flutter/lib/state/app_bar_state.dart +++ b/flutter/lib/state/app_bar_state.dart @@ -9,8 +9,6 @@ class AppBarState extends ChangeNotifier { AppBarState._internal() {} - List _searchListeners = []; - bool _loadingIndeterminate = false; bool get loadingIndeterminate => _loadingIndeterminate; @@ -28,18 +26,4 @@ class AppBarState extends ChangeNotifier { _showSearchField = v; notifyListeners(); } - - void subscribeSearchListener(void Function(String) listener) { - _searchListeners.add(listener); - } - - void unsubscribeSearchListener(void Function(String) listener) { - _searchListeners.remove(listener); - } - - void notifySearchListeners(String query) { - for (var listener in _searchListeners) { - listener(query); - } - } } diff --git a/flutter/lib/state/app_events.dart b/flutter/lib/state/app_events.dart new file mode 100644 index 0000000..5b5a74a --- /dev/null +++ b/flutter/lib/state/app_events.dart @@ -0,0 +1,47 @@ +import 'package:simplecloudnotifier/models/scn_message.dart'; +import 'package:simplecloudnotifier/state/application_log.dart'; + +class AppEvents { + static AppEvents? _singleton = AppEvents._internal(); + + factory AppEvents() { + return _singleton ?? (_singleton = AppEvents._internal()); + } + + AppEvents._internal() {} + + List _searchListeners = []; + List _messageReceivedListeners = []; + + void subscribeSearchListener(void Function(String) listener) { + _searchListeners.add(listener); + } + + void unsubscribeSearchListener(void Function(String) listener) { + _searchListeners.remove(listener); + } + + void notifySearchListeners(String query) { + ApplicationLog.debug('[AppEvents] onSearch: $query'); + + for (var listener in _searchListeners) { + listener(query); + } + } + + void subscribeMessageReceivedListener(void Function(SCNMessage) listener) { + _messageReceivedListeners.add(listener); + } + + void unsubscribeMessageReceivedListener(void Function(SCNMessage) listener) { + _messageReceivedListeners.remove(listener); + } + + void notifyMessageReceivedListeners(SCNMessage msg) { + ApplicationLog.debug('[AppEvents] onMessageReceived: ${msg.messageID}'); + + for (var listener in _messageReceivedListeners) { + listener(msg); + } + } +} diff --git a/flutter/lib/state/scn_data_cache.dart b/flutter/lib/state/scn_data_cache.dart new file mode 100644 index 0000000..f5da15a --- /dev/null +++ b/flutter/lib/state/scn_data_cache.dart @@ -0,0 +1,60 @@ +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:simplecloudnotifier/models/channel.dart'; +import 'package:simplecloudnotifier/models/scn_message.dart'; +import 'package:simplecloudnotifier/settings/app_settings.dart'; + +class SCNDataCache { + SCNDataCache._internal(); + static final SCNDataCache _instance = SCNDataCache._internal(); + factory SCNDataCache() => _instance; + + Future addToMessageCache(List newItems) async { + final cfg = AppSettings(); + + 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 < cfg.messagePageSize) return; + + final allValues = cache.values.toList(); + + allValues.sort((a, b) => -1 * a.timestamp.compareTo(b.timestamp)); + + for (var val in allValues.sublist(cfg.messagePageSize)) { + await cache.delete(val.messageID); + } + } + + 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); + } + + bool hasMessagesAndChannels() { + final chnCache = Hive.box('scn-channel-cache'); + final msgCache = Hive.box('scn-message-cache'); + + return chnCache.isNotEmpty && msgCache.isNotEmpty; + } + + Map getChannelMap() { + final chnCache = Hive.box('scn-channel-cache'); + + return {for (var v in chnCache.values) v.channelID: v}; + } + + List getMessagesSorted() { + final msgCache = Hive.box('scn-message-cache'); + + final cacheMessages = msgCache.values.toList(); + cacheMessages.sort((a, b) => -1 * a.timestamp.compareTo(b.timestamp)); + + return cacheMessages; + } +} From 95424055128e134b1efd8d4893ff34d92e04671c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Tue, 18 Jun 2024 17:36:41 +0200 Subject: [PATCH 07/16] fix linebreaks in message.title in channel_list_item --- .../lib/pages/channel_list/channel_list_item.dart | 7 ++++++- flutter/lib/pages/message_list/message_list.dart | 12 +++++------- flutter/lib/settings/app_settings.dart | 3 ++- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/flutter/lib/pages/channel_list/channel_list_item.dart b/flutter/lib/pages/channel_list/channel_list_item.dart index ddadde7..11e715c 100644 --- a/flutter/lib/pages/channel_list/channel_list_item.dart +++ b/flutter/lib/pages/channel_list/channel_list_item.dart @@ -76,7 +76,7 @@ class _ChannelListItemState extends State { children: [ Expanded( child: Text( - lastMessage?.title ?? '...', + _preformatTitle(lastMessage), style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)), ), ), @@ -89,4 +89,9 @@ class _ChannelListItemState extends State { ), ); } + + String _preformatTitle(SCNMessage? message) { + if (message == null) return '...'; + return message.title.replaceAll('\n', '').replaceAll('\r', '').replaceAll('\t', ' '); + } } diff --git a/flutter/lib/pages/message_list/message_list.dart b/flutter/lib/pages/message_list/message_list.dart index 1277a6e..9dd5ea2 100644 --- a/flutter/lib/pages/message_list/message_list.dart +++ b/flutter/lib/pages/message_list/message_list.dart @@ -1,5 +1,4 @@ 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'; @@ -21,9 +20,6 @@ class MessageListPage extends StatefulWidget { final bool isVisiblePage; - //TODO reload on switch to tab - //TODO reload on app to foreground - @override State createState() => _MessageListPageState(); } @@ -111,15 +107,17 @@ class _MessageListPageState extends State with RouteAware { @override void didPopNext() { - if (AppSettings().alwaysBackgroundRefreshMessageListOnPop) { + if (AppSettings().backgroundRefreshMessageListOnPop) { ApplicationLog.debug('[MessageList::RouteObserver] --> didPopNext (will background-refresh)'); _backgroundRefresh(false); } } void _onLifecycleResume() { - ApplicationLog.debug('[MessageList::_onLifecycleResume] --> (will background-refresh)'); - _backgroundRefresh(false); + if (AppSettings().alwaysBackgroundRefreshMessageListOnLifecycleResume) { + ApplicationLog.debug('[MessageList::_onLifecycleResume] --> (will background-refresh)'); + _backgroundRefresh(false); + } } Future _fetchPage(String thisPageToken) async { diff --git a/flutter/lib/settings/app_settings.dart b/flutter/lib/settings/app_settings.dart index 76df7ed..424ce06 100644 --- a/flutter/lib/settings/app_settings.dart +++ b/flutter/lib/settings/app_settings.dart @@ -4,7 +4,8 @@ class AppSettings extends ChangeNotifier { bool groupNotifications = true; int messagePageSize = 128; bool showDebugButton = true; - bool alwaysBackgroundRefreshMessageListOnPop = false; + bool backgroundRefreshMessageListOnPop = false; + bool alwaysBackgroundRefreshMessageListOnLifecycleResume = true; static AppSettings? _singleton = AppSettings._internal(); From 7dad61dbbb3af41c7284c47b98ac059d503b3e3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Sun, 23 Jun 2024 13:31:10 +0200 Subject: [PATCH 08/16] Fix re-layout in message_view after data is loaded --- .../lib/pages/message_view/message_view.dart | 58 +++++++++++++++++-- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/flutter/lib/pages/message_view/message_view.dart b/flutter/lib/pages/message_view/message_view.dart index d49ccc5..d3446e9 100644 --- a/flutter/lib/pages/message_view/message_view.dart +++ b/flutter/lib/pages/message_view/message_view.dart @@ -132,12 +132,58 @@ class _MessageViewPageState extends State { 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?.displayName ?? message.channelInternalName], () => {/*TODO*/}), - _buildMetaCard(context, FontAwesomeIcons.solidTimer, 'Timestamp', [message.timestamp], null), - _buildMetaCard(context, FontAwesomeIcons.solidUser, 'User', [if (user != null) user.userID, if (user?.username != null) user!.username!], () => {/*TODO*/}), //TODO - _buildMetaCard(context, FontAwesomeIcons.solidBolt, 'Priority', [_prettyPrintPriority(message.priority)], () => {/*TODO*/}), //TODO + _buildMetaCard( + context, + FontAwesomeIcons.solidGearCode, + 'KeyToken', + [ + message.usedKeyID, + token?.name ?? '...', + ], + () => {/*TODO*/}), + _buildMetaCard( + context, + FontAwesomeIcons.solidIdCardClip, + 'MessageID', + [ + message.messageID, + message.userMessageID ?? '', + ], + null), + _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', + [ + user?.userID ?? '...', + user?.username ?? '', + ], + () => {/*TODO*/}), //TODO + _buildMetaCard( + context, + FontAwesomeIcons.solidBolt, + 'Priority', + [ + _prettyPrintPriority(message.priority), + ], + () => {/*TODO*/}), //TODO if (message.senderUserID == userAccUserID) UI.button(text: "Delete Message", onPressed: () {/*TODO*/}, color: Colors.red[900]), ], ), From e2dbe8866dd3cc3294678fc65e78804796c49dbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Tue, 25 Jun 2024 12:00:34 +0200 Subject: [PATCH 09/16] Channel List/view WIP --- flutter/lib/components/layout/app_bar.dart | 1 + flutter/lib/models/channel.dart | 4 +- flutter/lib/nav_layout.dart | 2 +- .../lib/pages/channel_list/channel_list.dart | 23 ++- .../pages/channel_list/channel_list_item.dart | 74 +++++--- .../lib/pages/channel_view/channel_view.dart | 165 ++++++++++++++++++ .../lib/pages/message_list/message_list.dart | 2 +- 7 files changed, 232 insertions(+), 39 deletions(-) create mode 100644 flutter/lib/pages/channel_view/channel_view.dart diff --git a/flutter/lib/components/layout/app_bar.dart b/flutter/lib/components/layout/app_bar.dart index 64f59c0..b171c7a 100644 --- a/flutter/lib/components/layout/app_bar.dart +++ b/flutter/lib/components/layout/app_bar.dart @@ -90,6 +90,7 @@ class _SCNAppBarState extends State { )); } else { actions.add(_buildSpacer()); + actions.add(_buildSpacer()); } return Consumer(builder: (context, value, child) { diff --git a/flutter/lib/models/channel.dart b/flutter/lib/models/channel.dart index 9588fa4..a2d1c6f 100644 --- a/flutter/lib/models/channel.dart +++ b/flutter/lib/models/channel.dart @@ -74,7 +74,7 @@ class Channel extends HiveObject implements FieldDebuggable { class ChannelWithSubscription { final Channel channel; - final Subscription subscription; + final Subscription? subscription; ChannelWithSubscription({ required this.channel, @@ -84,7 +84,7 @@ class ChannelWithSubscription { factory ChannelWithSubscription.fromJson(Map json) { return ChannelWithSubscription( channel: Channel.fromJson(json), - subscription: Subscription.fromJson(json['subscription'] as Map), + subscription: json['subscription'] == null ? null : Subscription.fromJson(json['subscription'] as Map), ); } diff --git a/flutter/lib/nav_layout.dart b/flutter/lib/nav_layout.dart index b08b90f..cd9dafd 100644 --- a/flutter/lib/nav_layout.dart +++ b/flutter/lib/nav_layout.dart @@ -59,7 +59,7 @@ class _SCNNavLayoutState extends State { return Scaffold( appBar: SCNAppBar( title: null, - showSearch: _selectedIndex == 0 || _selectedIndex == 1, + showSearch: _selectedIndex == 0, showShare: false, showThemeSwitch: true, ), diff --git a/flutter/lib/pages/channel_list/channel_list.dart b/flutter/lib/pages/channel_list/channel_list.dart index fa0455f..f118a90 100644 --- a/flutter/lib/pages/channel_list/channel_list.dart +++ b/flutter/lib/pages/channel_list/channel_list.dart @@ -3,10 +3,12 @@ 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/pages/channel_view/channel_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/channel_list/channel_list_item.dart'; +import 'package:simplecloudnotifier/utils/navi.dart'; class ChannelRootPage extends StatefulWidget { const ChannelRootPage({super.key, required this.isVisiblePage}); @@ -18,7 +20,7 @@ class ChannelRootPage extends StatefulWidget { } class _ChannelRootPageState extends State { - final PagingController _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: 0); + final PagingController _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: 0); bool _isInitialized = false; @@ -68,9 +70,9 @@ class _ChannelRootPageState extends State { } try { - final items = (await APIClient.getChannelList(acc, ChannelSelector.all)).map((p) => p.channel).toList(); + final items = (await APIClient.getChannelList(acc, ChannelSelector.all)).toList(); - items.sort((a, b) => -1 * (a.timestampLastSent ?? '').compareTo(b.timestampLastSent ?? '')); + items.sort((a, b) => -1 * (a.channel.timestampLastSent ?? '').compareTo(b.channel.timestampLastSent ?? '')); _pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null); } catch (exc, trace) { @@ -94,9 +96,9 @@ class _ChannelRootPageState extends State { AppBarState().setLoadingIndeterminate(true); - final items = (await APIClient.getChannelList(acc, ChannelSelector.all)).map((p) => p.channel).toList(); + final items = (await APIClient.getChannelList(acc, ChannelSelector.all)).toList(); - items.sort((a, b) => -1 * (a.timestampLastSent ?? '').compareTo(b.timestampLastSent ?? '')); + items.sort((a, b) => -1 * (a.channel.timestampLastSent ?? '').compareTo(b.channel.timestampLastSent ?? '')); _pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null); } catch (exc, trace) { @@ -113,12 +115,15 @@ class _ChannelRootPageState extends State { onRefresh: () => Future.sync( () => _pagingController.refresh(), ), - child: PagedListView( + child: PagedListView( pagingController: _pagingController, - builderDelegate: PagedChildBuilderDelegate( + builderDelegate: PagedChildBuilderDelegate( itemBuilder: (context, item, index) => ChannelListItem( - channel: item, - onPressed: () {/*TODO*/}, + channel: item.channel, + subscription: item.subscription, + onPressed: () { + Navi.push(context, () => ChannelViewPage(channel: item.channel, subscription: item.subscription)); + }, ), ), ), diff --git a/flutter/lib/pages/channel_list/channel_list_item.dart b/flutter/lib/pages/channel_list/channel_list_item.dart index 11e715c..5aeb4fc 100644 --- a/flutter/lib/pages/channel_list/channel_list_item.dart +++ b/flutter/lib/pages/channel_list/channel_list_item.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/scn_message.dart'; +import 'package:simplecloudnotifier/models/subscription.dart'; import 'package:simplecloudnotifier/state/app_auth.dart'; class ChannelListItem extends StatefulWidget { @@ -12,10 +14,12 @@ class ChannelListItem extends StatefulWidget { const ChannelListItem({ required this.channel, required this.onPressed, + required this.subscription, super.key, }); final Channel channel; + final Subscription? subscription; final Null Function() onPressed; @override @@ -53,35 +57,43 @@ class _ChannelListItemState extends State { onTap: widget.onPressed, child: Padding( padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + child: Row( children: [ - Row( - children: [ - Expanded( - child: Text( - widget.channel.displayName, - style: const TextStyle(fontWeight: FontWeight.bold), + _buildIcon(context), + SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + widget.channel.displayName, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + Text( + (widget.channel.timestampLastSent == null) ? '' : ChannelListItem._dateFormat.format(DateTime.parse(widget.channel.timestampLastSent!).toLocal()), + style: const TextStyle(fontSize: 14), + ), + ], ), - ), - Text( - (widget.channel.timestampLastSent == null) ? '' : ChannelListItem._dateFormat.format(DateTime.parse(widget.channel.timestampLastSent!).toLocal()), - style: const TextStyle(fontSize: 14), - ), - ], - ), - SizedBox(height: 4), - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: Text( - _preformatTitle(lastMessage), - style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)), + SizedBox(height: 4), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Text( + _preformatTitle(lastMessage), + style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)), + ), + ), + Text(widget.channel.messagesSent.toString(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), + ], ), - ), - Text(widget.channel.messagesSent.toString(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), - ], + ], + ), ), ], ), @@ -94,4 +106,14 @@ class _ChannelListItemState extends State { if (message == null) return '...'; return message.title.replaceAll('\n', '').replaceAll('\r', '').replaceAll('\t', ' '); } + + Widget _buildIcon(BuildContext context) { + if (widget.subscription == null) { + return Icon(FontAwesomeIcons.solidSquareDashed, color: Theme.of(context).colorScheme.outline, size: 32); // not-subscribed + } else if (widget.subscription!.confirmed) { + return Icon(FontAwesomeIcons.solidSquareRss, color: Theme.of(context).colorScheme.onPrimaryContainer, size: 32); // subscribed + } else { + return Icon(FontAwesomeIcons.solidSquareEnvelope, color: Theme.of(context).colorScheme.tertiary, size: 32); // requested + } + } } diff --git a/flutter/lib/pages/channel_view/channel_view.dart b/flutter/lib/pages/channel_view/channel_view.dart new file mode 100644 index 0000000..5a76d92 --- /dev/null +++ b/flutter/lib/pages/channel_view/channel_view.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:simplecloudnotifier/api/api_client.dart'; +import 'package:simplecloudnotifier/components/layout/scaffold.dart'; +import 'package:simplecloudnotifier/models/channel.dart'; +import 'package:simplecloudnotifier/models/keytoken.dart'; +import 'package:simplecloudnotifier/models/scn_message.dart'; +import 'package:simplecloudnotifier/models/subscription.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'; + +class ChannelViewPage extends StatefulWidget { + const ChannelViewPage({ + required this.channel, + required this.subscription, + super.key, + }); + + final Channel channel; + final Subscription? subscription; + + @override + State createState() => _ChannelViewPageState(); +} + +class _ChannelViewPageState extends State { + static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm'); + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SCNScaffold( + title: 'Channel', + showSearch: false, + showShare: false, + child: _buildChannelView(context), + ); + } + + Widget _buildChannelView(BuildContext context) { + final userAccUserID = context.select((v) => v.userID); + + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ..._buildChannelHeader(context), + SizedBox(height: 8), + _buildQRCode(context), + SizedBox(height: 8), + //TODO icons + _buildMetaCard(context, FontAwesomeIcons.solidQuestion, 'ChannelID', ['...'], null), + _buildMetaCard(context, FontAwesomeIcons.solidQuestion, 'InternalName', ['...'], null), + _buildMetaCard(context, FontAwesomeIcons.solidQuestion, 'DisplayName', ['...'], null), //TODO edit icon on right to edit name + _buildMetaCard(context, FontAwesomeIcons.solidQuestion, 'Subscription (own)', ['...'], null), //TODO sub/unsub icon on right + //TODO list foreign subscriptions (with accept/decline/delete button on right) + _buildMetaCard(context, FontAwesomeIcons.solidQuestion, 'Messages', ['...'], () {/*TODO*/}), + ], + ), + ), + ); + } + + List _buildChannelHeader(BuildContext context) { + return [ + Text(widget.channel.displayName, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + ]; + } + + Widget _buildMetaCard(BuildContext context, IconData icn, String title, List values, void Function()? action) { + final container = UI.box( + context: context, + padding: EdgeInsets.fromLTRB(16, 2, 4, 2), + child: Row( + children: [ + FaIcon(icn, size: 18), + SizedBox(width: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + for (final val in values) Text(val, style: const TextStyle(fontSize: 14)), + ], + ), + ], + ), + ); + + if (action == null) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0), + child: container, + ); + } else { + return Padding( + padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0), + child: InkWell( + splashColor: Theme.of(context).splashColor, + onTap: action, + child: container, + ), + ); + } + } + + String _preformatTitle(SCNMessage message) { + return message.title.replaceAll('\n', '').replaceAll('\r', '').replaceAll('\t', ' '); + } + + String _prettyPrintPriority(int priority) { + switch (priority) { + case 0: + return 'Low (0)'; + case 1: + return 'Normal (1)'; + case 2: + return 'High (2)'; + default: + return 'Unknown ($priority)'; + } + } + + Widget _buildQRCode(BuildContext context) { + var text = 'TODO' + widget.channel.channelID; //TODO subkey+channelid with deeplink-y + return GestureDetector( + onTap: () { + //TODO share + }, + child: Center( + child: QrImageView( + data: text, + version: QrVersions.auto, + size: 300.0, + eyeStyle: QrEyeStyle( + eyeShape: QrEyeShape.square, + color: Theme.of(context).textTheme.bodyLarge?.color, + ), + dataModuleStyle: QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.square, + color: Theme.of(context).textTheme.bodyLarge?.color, + ), + ), + ), + ); + } +} diff --git a/flutter/lib/pages/message_list/message_list.dart b/flutter/lib/pages/message_list/message_list.dart index 9dd5ea2..bbe4cb0 100644 --- a/flutter/lib/pages/message_list/message_list.dart +++ b/flutter/lib/pages/message_list/message_list.dart @@ -114,7 +114,7 @@ class _MessageListPageState extends State with RouteAware { } void _onLifecycleResume() { - if (AppSettings().alwaysBackgroundRefreshMessageListOnLifecycleResume) { + if (AppSettings().alwaysBackgroundRefreshMessageListOnLifecycleResume && widget.isVisiblePage) { ApplicationLog.debug('[MessageList::_onLifecycleResume] --> (will background-refresh)'); _backgroundRefresh(false); } From 2b234044619a442afd921eaa74d39e1febdbcd65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Tue, 25 Jun 2024 20:49:40 +0200 Subject: [PATCH 10/16] channel_view page --- flutter/lib/api/api_client.dart | 10 + flutter/lib/models/user.dart | 4 + .../pages/channel_list/channel_list_item.dart | 4 +- .../lib/pages/channel_view/channel_view.dart | 336 +++++++++++++----- .../lib/pages/message_view/message_view.dart | 137 +++---- flutter/lib/state/app_auth.dart | 8 + flutter/lib/utils/ui.dart | 45 +++ 7 files changed, 367 insertions(+), 177 deletions(-) diff --git a/flutter/lib/api/api_client.dart b/flutter/lib/api/api_client.dart index ceb3f25..82c6a1e 100644 --- a/flutter/lib/api/api_client.dart +++ b/flutter/lib/api/api_client.dart @@ -247,6 +247,16 @@ class APIClient { ); } + static Future> getChannelSubscriptions(TokenSource auth, String cid) async { + return await _request( + name: 'getChannelSubscriptions', + method: 'GET', + relURL: 'users/${auth.getUserID()}/channels/${cid}/subscriptions', + fn: (json) => Subscription.fromJsonArray(json['subscriptions'] as List), + authToken: auth.getToken(), + ); + } + static Future> getClientList(TokenSource auth) async { return await _request( name: 'getClientList', diff --git a/flutter/lib/models/user.dart b/flutter/lib/models/user.dart index b7f9cfa..f0bd9cb 100644 --- a/flutter/lib/models/user.dart +++ b/flutter/lib/models/user.dart @@ -86,6 +86,10 @@ class User { 'max_user_message_id_length': maxUserMessageIDLength, }; } + + UserPreview toPreview() { + return UserPreview(userID: userID, username: username); + } } class UserWithClientsAndKeys { diff --git a/flutter/lib/pages/channel_list/channel_list_item.dart b/flutter/lib/pages/channel_list/channel_list_item.dart index 5aeb4fc..1047167 100644 --- a/flutter/lib/pages/channel_list/channel_list_item.dart +++ b/flutter/lib/pages/channel_list/channel_list_item.dart @@ -110,8 +110,10 @@ class _ChannelListItemState extends State { Widget _buildIcon(BuildContext context) { if (widget.subscription == null) { return Icon(FontAwesomeIcons.solidSquareDashed, color: Theme.of(context).colorScheme.outline, size: 32); // not-subscribed + } else if (widget.subscription!.confirmed && widget.channel.ownerUserID == widget.subscription!.subscriberUserID) { + return Icon(FontAwesomeIcons.solidSquareRss, color: Theme.of(context).colorScheme.onPrimaryContainer, size: 32); // subscribed (own channel) } else if (widget.subscription!.confirmed) { - return Icon(FontAwesomeIcons.solidSquareRss, color: Theme.of(context).colorScheme.onPrimaryContainer, size: 32); // subscribed + return Icon(FontAwesomeIcons.solidSquareShareNodes, color: Theme.of(context).colorScheme.onPrimaryContainer, size: 32); // subscribed (foreign channel) } else { return Icon(FontAwesomeIcons.solidSquareEnvelope, color: Theme.of(context).colorScheme.tertiary, size: 32); // requested } diff --git a/flutter/lib/pages/channel_view/channel_view.dart b/flutter/lib/pages/channel_view/channel_view.dart index 5a76d92..31857ab 100644 --- a/flutter/lib/pages/channel_view/channel_view.dart +++ b/flutter/lib/pages/channel_view/channel_view.dart @@ -1,21 +1,17 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:intl/intl.dart'; -import 'package:provider/provider.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:share_plus/share_plus.dart'; import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/components/layout/scaffold.dart'; import 'package:simplecloudnotifier/models/channel.dart'; -import 'package:simplecloudnotifier/models/keytoken.dart'; -import 'package:simplecloudnotifier/models/scn_message.dart'; import 'package:simplecloudnotifier/models/subscription.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/types/immediate_future.dart'; import 'package:simplecloudnotifier/utils/ui.dart'; +import 'package:provider/provider.dart'; class ChannelViewPage extends StatefulWidget { const ChannelViewPage({ @@ -32,10 +28,39 @@ class ChannelViewPage extends StatefulWidget { } class _ChannelViewPageState extends State { - static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm'); + late ImmediateFuture _futureSubscribeKey; + late ImmediateFuture> _futureSubscriptions; + late ImmediateFuture _futureOwner; + + int _loadingIndeterminateCounter = 0; @override void initState() { + final userAcc = Provider.of(context, listen: false); + + if (widget.channel.ownerUserID == userAcc.userID) { + if (widget.channel.subscribeKey != null) { + _futureSubscribeKey = ImmediateFuture.ofValue(widget.channel.subscribeKey); + } else { + _futureSubscribeKey = ImmediateFuture.ofFuture(_getSubScribeKey(userAcc)); + } + _futureSubscriptions = ImmediateFuture>.ofFuture(_listSubscriptions(userAcc)); + } else { + _futureSubscribeKey = ImmediateFuture.ofValue(null); + _futureSubscriptions = ImmediateFuture>.ofValue([]); + } + + if (widget.channel.ownerUserID == userAcc.userID) { + var cacheUser = userAcc.getUserOrNull(); + if (cacheUser != null) { + _futureOwner = ImmediateFuture.ofValue(cacheUser.toPreview()); + } else { + _futureOwner = ImmediateFuture.ofFuture(_getOwner(userAcc)); + } + } else { + _futureOwner = ImmediateFuture.ofFuture(APIClient.getUserPreview(userAcc, widget.channel.ownerUserID)); + } + super.initState(); } @@ -57,109 +82,246 @@ class _ChannelViewPageState extends State { Widget _buildChannelView(BuildContext context) { final userAccUserID = context.select((v) => v.userID); + final isOwned = (widget.channel.ownerUserID == userAccUserID); + final isSubscribed = (widget.subscription != null && widget.subscription!.confirmed); + return SingleChildScrollView( child: Padding( padding: const EdgeInsets.fromLTRB(24, 16, 24, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - ..._buildChannelHeader(context), - SizedBox(height: 8), _buildQRCode(context), SizedBox(height: 8), - //TODO icons - _buildMetaCard(context, FontAwesomeIcons.solidQuestion, 'ChannelID', ['...'], null), - _buildMetaCard(context, FontAwesomeIcons.solidQuestion, 'InternalName', ['...'], null), - _buildMetaCard(context, FontAwesomeIcons.solidQuestion, 'DisplayName', ['...'], null), //TODO edit icon on right to edit name - _buildMetaCard(context, FontAwesomeIcons.solidQuestion, 'Subscription (own)', ['...'], null), //TODO sub/unsub icon on right - //TODO list foreign subscriptions (with accept/decline/delete button on right) - _buildMetaCard(context, FontAwesomeIcons.solidQuestion, 'Messages', ['...'], () {/*TODO*/}), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidIdCardClip, + title: 'ChannelID', + values: [widget.channel.channelID], + ), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidInputNumeric, + title: 'InternalName', + values: [widget.channel.internalName], + ), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidInputText, + title: 'DisplayName', + values: [widget.channel.displayName], + iconActions: isOwned ? [(FontAwesomeIcons.penToSquare, _rename)] : [], + ), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidDiagramSubtask, + title: 'Subscription (own)', + values: [_formatSubscriptionStatus(widget.subscription)], + iconActions: isSubscribed ? [(FontAwesomeIcons.solidSquareXmark, _unsubscribe)] : [(FontAwesomeIcons.solidSquareRss, _subscribe)], + ), + _buildForeignSubscriptions(context), + _buildOwnerCard(context, isOwned), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidEnvelope, + title: 'Messages', + values: [widget.channel.messagesSent.toString()], + mainAction: () {/*TODO*/}, + ), ], ), ), ); } - List _buildChannelHeader(BuildContext context) { - return [ - Text(widget.channel.displayName, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - ]; - } - - Widget _buildMetaCard(BuildContext context, IconData icn, String title, List values, void Function()? action) { - final container = UI.box( - context: context, - padding: EdgeInsets.fromLTRB(16, 2, 4, 2), - child: Row( - children: [ - FaIcon(icn, size: 18), - SizedBox(width: 16), - Column( - crossAxisAlignment: CrossAxisAlignment.start, + Widget _buildForeignSubscriptions(BuildContext context) { + return FutureBuilder( + future: _futureSubscriptions.future, + builder: (context, snapshot) { + if (snapshot.hasData) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), - for (final val in values) Text(val, style: const TextStyle(fontSize: 14)), + for (final sub in snapshot.data!.where((sub) => sub.subscriptionID != widget.subscription?.subscriptionID)) + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidDiagramSuccessor, + title: 'Subscription (other)', + values: [_formatSubscriptionStatus(sub)], + iconActions: _getForignSubActions(sub), + ), ], - ), - ], - ), + ); + } else { + return SizedBox(); + } + }, ); - - if (action == null) { - return Padding( - padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0), - child: container, - ); - } else { - return Padding( - padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0), - child: InkWell( - splashColor: Theme.of(context).splashColor, - onTap: action, - child: container, - ), - ); - } } - String _preformatTitle(SCNMessage message) { - return message.title.replaceAll('\n', '').replaceAll('\r', '').replaceAll('\t', ' '); - } - - String _prettyPrintPriority(int priority) { - switch (priority) { - case 0: - return 'Low (0)'; - case 1: - return 'Normal (1)'; - case 2: - return 'High (2)'; - default: - return 'Unknown ($priority)'; - } + Widget _buildOwnerCard(BuildContext context, bool isOwned) { + return FutureBuilder( + future: _futureOwner.future, + builder: (context, snapshot) { + if (snapshot.hasData) { + return UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidUser, + title: 'Owner', + values: [widget.channel.ownerUserID + (isOwned ? ' (you)' : ''), if (snapshot.data?.username != null) snapshot.data!.username!], + ); + } else { + return UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidUser, + title: 'Owner', + values: [widget.channel.ownerUserID + (isOwned ? ' (you)' : '')], + ); + } + }, + ); } Widget _buildQRCode(BuildContext context) { - var text = 'TODO' + widget.channel.channelID; //TODO subkey+channelid with deeplink-y - return GestureDetector( - onTap: () { - //TODO share + return FutureBuilder( + future: _futureSubscribeKey.future, + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data != null) { + var text = 'TODO' + '\n' + widget.channel.channelID + '\n' + snapshot.data!; //TODO deeplink-y (also perhaps just bas64 everything together?) + return GestureDetector( + onTap: () { + Share.share(text, subject: widget.channel.displayName); + }, + child: Center( + child: QrImageView( + data: text, + version: QrVersions.auto, + size: 300.0, + eyeStyle: QrEyeStyle( + eyeShape: QrEyeShape.square, + color: Theme.of(context).textTheme.bodyLarge?.color, + ), + dataModuleStyle: QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.square, + color: Theme.of(context).textTheme.bodyLarge?.color, + ), + ), + ), + ); + } else if (snapshot.hasData && snapshot.data == null) { + return const SizedBox( + width: 300.0, + height: 300.0, + child: Center(child: Icon(FontAwesomeIcons.solidSnake, size: 64)), + ); + } else { + return const SizedBox( + width: 300.0, + height: 300.0, + child: Center(child: CircularProgressIndicator()), + ); + } }, - child: Center( - child: QrImageView( - data: text, - version: QrVersions.auto, - size: 300.0, - eyeStyle: QrEyeStyle( - eyeShape: QrEyeShape.square, - color: Theme.of(context).textTheme.bodyLarge?.color, - ), - dataModuleStyle: QrDataModuleStyle( - dataModuleShape: QrDataModuleShape.square, - color: Theme.of(context).textTheme.bodyLarge?.color, - ), - ), - ), ); } + + void _rename() { + //TODO + } + + void _subscribe() { + //TODO + } + + void _unsubscribe() { + //TODO + } + + void _cancelForeignSubscription(Subscription sub) { + //TODO + } + + void _confirmForeignSubscription(Subscription sub) { + //TODO + } + + void _denyForeignSubscription(Subscription sub) { + //TODO + } + + String _formatSubscriptionStatus(Subscription? subscription) { + if (subscription == null) { + return 'Not Subscribed'; + } else if (subscription.confirmed) { + return 'Subscribed'; + } else { + return 'Requested'; + } + } + + Future _getSubScribeKey(AppAuth auth) async { + try { + await Future.delayed(const Duration(seconds: 0), () {}); // this is annoyingly important - otherwise we call setLoadingIndeterminate directly in initStat() and get an exception.... + + _incLoadingIndeterminateCounter(1); + + var channel = await APIClient.getChannel(auth, widget.channel.channelID); + + //await Future.delayed(const Duration(seconds: 10), () {}); + + return channel.channel.subscribeKey; + } finally { + _incLoadingIndeterminateCounter(-1); + } + } + + Future> _listSubscriptions(AppAuth auth) async { + try { + await Future.delayed(const Duration(seconds: 0), () {}); // this is annoyingly important - otherwise we call setLoadingIndeterminate directly in initStat() and get an exception.... + + _incLoadingIndeterminateCounter(1); + + var subs = await APIClient.getChannelSubscriptions(auth, widget.channel.channelID); + + //await Future.delayed(const Duration(seconds: 10), () {}); + + return subs; + } finally { + _incLoadingIndeterminateCounter(-1); + } + } + + Future _getOwner(AppAuth auth) async { + try { + await Future.delayed(const Duration(seconds: 0), () {}); // this is annoyingly important - otherwise we call setLoadingIndeterminate directly in initStat() and get an exception.... + + _incLoadingIndeterminateCounter(1); + + final owner = APIClient.getUserPreview(auth, widget.channel.ownerUserID); + + //await Future.delayed(const Duration(seconds: 10), () {}); + + return owner; + } finally { + _incLoadingIndeterminateCounter(-1); + } + } + + List<(IconData, void Function())> _getForignSubActions(Subscription sub) { + if (sub.confirmed) { + return [(FontAwesomeIcons.solidSquareXmark, () => _cancelForeignSubscription(sub))]; + } else { + return [ + (FontAwesomeIcons.solidSquareCheck, () => _confirmForeignSubscription(sub)), + (FontAwesomeIcons.solidSquareXmark, () => _denyForeignSubscription(sub)), + ]; + } + } + + void _incLoadingIndeterminateCounter(int delta) { + setState(() { + _loadingIndeterminateCounter += delta; + AppBarState().setLoadingIndeterminate(_loadingIndeterminateCounter > 0); + }); + } } diff --git a/flutter/lib/pages/message_view/message_view.dart b/flutter/lib/pages/message_view/message_view.dart index d3446e9..2877051 100644 --- a/flutter/lib/pages/message_view/message_view.dart +++ b/flutter/lib/pages/message_view/message_view.dart @@ -131,59 +131,54 @@ class _MessageViewPageState extends State { SizedBox(height: 8), 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, - token?.name ?? '...', - ], - () => {/*TODO*/}), - _buildMetaCard( - context, - FontAwesomeIcons.solidIdCardClip, - 'MessageID', - [ - message.messageID, - message.userMessageID ?? '', - ], - null), - _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', - [ - user?.userID ?? '...', - user?.username ?? '', - ], - () => {/*TODO*/}), //TODO - _buildMetaCard( - context, - FontAwesomeIcons.solidBolt, - 'Priority', - [ - _prettyPrintPriority(message.priority), - ], - () => {/*TODO*/}), //TODO + if (message.senderName != null) + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidSignature, + title: 'Sender', + values: [message.senderName!], + mainAction: () => {/*TODO*/}, + ), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidGearCode, + title: 'KeyToken', + values: [message.usedKeyID, token?.name ?? '...'], + mainAction: () => {/*TODO*/}, + ), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidIdCardClip, + title: 'MessageID', + values: [message.messageID, message.userMessageID ?? ''], + ), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidSnake, + title: 'Channel', + values: [message.channelID, channel?.displayName ?? message.channelInternalName], + mainAction: () => {/*TODO*/}, + ), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidTimer, + title: 'Timestamp', + values: [message.timestamp], + ), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidUser, + title: 'User', + values: [user?.userID ?? '...', user?.username ?? ''], + mainAction: () => {/*TODO*/}, + ), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidBolt, + title: 'Priority', + values: [_prettyPrintPriority(message.priority)], + mainAction: () => {/*TODO*/}, + ), if (message.senderUserID == userAccUserID) UI.button(text: "Delete Message", onPressed: () {/*TODO*/}, color: Colors.red[900]), ], ), @@ -260,42 +255,6 @@ class _MessageViewPageState extends State { ]; } - Widget _buildMetaCard(BuildContext context, IconData icn, String title, List values, void Function()? action) { - final container = UI.box( - context: context, - padding: EdgeInsets.fromLTRB(16, 2, 4, 2), - child: Row( - children: [ - FaIcon(icn, size: 18), - SizedBox(width: 16), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), - for (final val in values) Text(val, style: const TextStyle(fontSize: 14)), - ], - ), - ], - ), - ); - - if (action == null) { - return Padding( - padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0), - child: container, - ); - } else { - return Padding( - padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0), - child: InkWell( - splashColor: Theme.of(context).splashColor, - onTap: action, - child: container, - ), - ); - } - } - String _preformatTitle(SCNMessage message) { return message.title.replaceAll('\n', '').replaceAll('\r', '').replaceAll('\t', ' '); } diff --git a/flutter/lib/state/app_auth.dart b/flutter/lib/state/app_auth.dart index 7524f94..038feed 100644 --- a/flutter/lib/state/app_auth.dart +++ b/flutter/lib/state/app_auth.dart @@ -182,6 +182,10 @@ class AppAuth extends ChangeNotifier implements TokenSource { return user; } + User? getUserOrNull() { + return _user?.$1; + } + Future loadClient({bool force = false, Duration? forceIfOlder = null}) async { if (forceIfOlder != null && _client != null && _client!.$2.difference(DateTime.now()) > forceIfOlder) { force = true; @@ -212,6 +216,10 @@ class AppAuth extends ChangeNotifier implements TokenSource { } } + Client? getClientOrNull() { + return _client?.$1; + } + @override String getToken() { return _tokenAdmin!; diff --git a/flutter/lib/utils/ui.dart b/flutter/lib/utils/ui.dart index e7d57cd..4a81ad7 100644 --- a/flutter/lib/utils/ui.dart +++ b/flutter/lib/utils/ui.dart @@ -106,4 +106,49 @@ class UI { child: child, ); } + + static Widget metaCard({required BuildContext context, required IconData icon, required String title, required List values, void Function()? mainAction, List<(IconData, void Function())>? iconActions}) { + final container = UI.box( + context: context, + padding: EdgeInsets.fromLTRB(16, 2, 4, 2), + child: Row( + children: [ + FaIcon(icon, size: 18), + SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + for (final val in values) Text(val, style: const TextStyle(fontSize: 14)), + ], + ), + ), + if (iconActions != null) ...[ + SizedBox(width: 12), + for (final iconAction in iconActions) ...[ + SizedBox(width: 4), + IconButton(icon: FaIcon(iconAction.$1), onPressed: iconAction.$2), + ], + ], + ], + ), + ); + + if (mainAction == null) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0), + child: container, + ); + } else { + return Padding( + padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0), + child: InkWell( + splashColor: Theme.of(context).splashColor, + onTap: mainAction, + child: container, + ), + ); + } + } } From 1f9b65652dba66405235d60101cef60e3d8e6b2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Tue, 25 Jun 2024 20:54:03 +0200 Subject: [PATCH 11/16] get channel->lastMessage from cache before hot-loading --- flutter/lib/pages/channel_list/channel_list_item.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/flutter/lib/pages/channel_list/channel_list_item.dart b/flutter/lib/pages/channel_list/channel_list_item.dart index 1047167..8dff3de 100644 --- a/flutter/lib/pages/channel_list/channel_list_item.dart +++ b/flutter/lib/pages/channel_list/channel_list_item.dart @@ -7,6 +7,7 @@ import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/scn_message.dart'; import 'package:simplecloudnotifier/models/subscription.dart'; import 'package:simplecloudnotifier/state/app_auth.dart'; +import 'package:simplecloudnotifier/state/scn_data_cache.dart'; class ChannelListItem extends StatefulWidget { static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm'); @@ -36,6 +37,8 @@ class _ChannelListItemState extends State { final acc = Provider.of(context, listen: false); if (acc.isAuth()) { + lastMessage = SCNDataCache().getMessagesSorted().where((p) => p.channelID == widget.channel.channelID).firstOrNull; + () async { final (_, channelMessages) = await APIClient.getMessageList(acc, '@start', pageSize: 1, channelIDs: [widget.channel.channelID]); setState(() { From 89d1e0f641970ae63add9d7b503e43cae6562026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Wed, 26 Jun 2024 14:54:34 +0200 Subject: [PATCH 12/16] edit displayName/descriptionName of channel --- flutter/lib/api/api_client.dart | 15 ++ .../lib/pages/channel_list/channel_list.dart | 43 +++- .../lib/pages/channel_view/channel_view.dart | 206 +++++++++++++++++- 3 files changed, 250 insertions(+), 14 deletions(-) diff --git a/flutter/lib/api/api_client.dart b/flutter/lib/api/api_client.dart index 82c6a1e..8066b79 100644 --- a/flutter/lib/api/api_client.dart +++ b/flutter/lib/api/api_client.dart @@ -7,6 +7,7 @@ import 'package:simplecloudnotifier/models/client.dart'; import 'package:simplecloudnotifier/models/keytoken.dart'; import 'package:simplecloudnotifier/models/subscription.dart'; import 'package:simplecloudnotifier/models/user.dart'; +import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/globals.dart'; import 'package:simplecloudnotifier/state/request_log.dart'; @@ -211,6 +212,20 @@ class APIClient { ); } + static Future updateChannel(AppAuth auth, String cid, {String? displayName, String? descriptionName}) async { + return await _request( + name: 'updateChannel', + method: 'PATCH', + relURL: 'users/${auth.getUserID()}/channels/${cid}', + jsonBody: { + if (displayName != null) 'display_name': displayName, + if (descriptionName != null) 'description_name': descriptionName, + }, + fn: ChannelWithSubscription.fromJson, + authToken: auth.getToken(), + ); + } + static Future<(String, List)> getMessageList(TokenSource auth, String pageToken, {int? pageSize, List? channelIDs}) async { return await _request( name: 'getMessageList', diff --git a/flutter/lib/pages/channel_list/channel_list.dart b/flutter/lib/pages/channel_list/channel_list.dart index f118a90..e3323ba 100644 --- a/flutter/lib/pages/channel_list/channel_list.dart +++ b/flutter/lib/pages/channel_list/channel_list.dart @@ -19,11 +19,13 @@ class ChannelRootPage extends StatefulWidget { State createState() => _ChannelRootPageState(); } -class _ChannelRootPageState extends State { +class _ChannelRootPageState extends State with RouteAware { final PagingController _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: 0); bool _isInitialized = false; + bool _reloadEnqueued = false; + @override void initState() { super.initState(); @@ -33,10 +35,17 @@ class _ChannelRootPageState extends State { if (widget.isVisiblePage && !_isInitialized) _realInitState(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + Navi.modalRouteObserver.subscribe(this, ModalRoute.of(context)!); + } + @override void dispose() { ApplicationLog.debug('ChannelRootPage::dispose'); _pagingController.dispose(); + Navi.modalRouteObserver.unsubscribe(this); super.dispose(); } @@ -53,6 +62,24 @@ class _ChannelRootPageState extends State { } } + @override + void didPush() { + // ... + } + + @override + void didPopNext() { + if (_reloadEnqueued) { + ApplicationLog.debug('[ChannelList::RouteObserver] --> didPopNext (will background-refresh) (_reloadEnqueued == true)'); + () async { + _reloadEnqueued = false; + AppBarState().setLoadingIndeterminate(true); + await Future.delayed(const Duration(milliseconds: 500)); // prevents flutter bug where the whole process crashes ?!? + await _backgroundRefresh(); + }(); + } + } + void _realInitState() { ApplicationLog.debug('ChannelRootPage::_realInitState'); _pagingController.refresh(); @@ -100,9 +127,13 @@ class _ChannelRootPageState extends State { items.sort((a, b) => -1 * (a.channel.timestampLastSent ?? '').compareTo(b.channel.timestampLastSent ?? '')); - _pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null); + setState(() { + _pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null); + }); } catch (exc, trace) { - _pagingController.error = exc.toString(); + setState(() { + _pagingController.error = exc.toString(); + }); ApplicationLog.error('Failed to list channels: ' + exc.toString(), trace: trace); } finally { AppBarState().setLoadingIndeterminate(false); @@ -122,11 +153,15 @@ class _ChannelRootPageState extends State { channel: item.channel, subscription: item.subscription, onPressed: () { - Navi.push(context, () => ChannelViewPage(channel: item.channel, subscription: item.subscription)); + Navi.push(context, () => ChannelViewPage(channel: item.channel, subscription: item.subscription, needsReload: _enqueueReload)); }, ), ), ), ); } + + void _enqueueReload() { + _reloadEnqueued = true; + } } diff --git a/flutter/lib/pages/channel_view/channel_view.dart b/flutter/lib/pages/channel_view/channel_view.dart index 31857ab..404fdbe 100644 --- a/flutter/lib/pages/channel_view/channel_view.dart +++ b/flutter/lib/pages/channel_view/channel_view.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:share_plus/share_plus.dart'; @@ -9,7 +10,9 @@ import 'package:simplecloudnotifier/models/subscription.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/state/application_log.dart'; import 'package:simplecloudnotifier/types/immediate_future.dart'; +import 'package:simplecloudnotifier/utils/toaster.dart'; import 'package:simplecloudnotifier/utils/ui.dart'; import 'package:provider/provider.dart'; @@ -17,23 +20,37 @@ class ChannelViewPage extends StatefulWidget { const ChannelViewPage({ required this.channel, required this.subscription, + required this.needsReload, super.key, }); final Channel channel; final Subscription? subscription; + final void Function()? needsReload; + @override State createState() => _ChannelViewPageState(); } +enum EditState { none, editing, saving } + class _ChannelViewPageState extends State { late ImmediateFuture _futureSubscribeKey; late ImmediateFuture> _futureSubscriptions; late ImmediateFuture _futureOwner; + final TextEditingController _ctrlDisplayName = TextEditingController(); + final TextEditingController _ctrlDescriptionName = TextEditingController(); + int _loadingIndeterminateCounter = 0; + EditState _editDisplayName = EditState.none; + String? _displayNameOverride = null; + + EditState _editDescriptionName = EditState.none; + String? _descriptionNameOverride = null; + @override void initState() { final userAcc = Provider.of(context, listen: false); @@ -66,6 +83,8 @@ class _ChannelViewPageState extends State { @override void dispose() { + _ctrlDisplayName.dispose(); + _ctrlDescriptionName.dispose(); super.dispose(); } @@ -105,13 +124,8 @@ class _ChannelViewPageState extends State { title: 'InternalName', values: [widget.channel.internalName], ), - UI.metaCard( - context: context, - icon: FontAwesomeIcons.solidInputText, - title: 'DisplayName', - values: [widget.channel.displayName], - iconActions: isOwned ? [(FontAwesomeIcons.penToSquare, _rename)] : [], - ), + _buildDisplayNameCard(context, isOwned), + _buildDescriptionNameCard(context, isOwned), UI.metaCard( context: context, icon: FontAwesomeIcons.solidDiagramSubtask, @@ -190,7 +204,7 @@ class _ChannelViewPageState extends State { var text = 'TODO' + '\n' + widget.channel.channelID + '\n' + snapshot.data!; //TODO deeplink-y (also perhaps just bas64 everything together?) return GestureDetector( onTap: () { - Share.share(text, subject: widget.channel.displayName); + Share.share(text, subject: _displayNameOverride ?? widget.channel.displayName); }, child: Center( child: QrImageView( @@ -225,8 +239,116 @@ class _ChannelViewPageState extends State { ); } - void _rename() { - //TODO + Widget _buildDisplayNameCard(BuildContext context, bool isOwned) { + if (_editDisplayName == EditState.editing) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0), + child: UI.box( + context: context, + padding: EdgeInsets.fromLTRB(16, 2, 4, 2), + child: Row( + children: [ + Container(child: Center(child: FaIcon(FontAwesomeIcons.solidInputText, size: 18)), height: 43), + SizedBox(width: 16), + Expanded( + child: TextField( + autofocus: true, + controller: _ctrlDisplayName, + decoration: new InputDecoration.collapsed(hintText: 'DisplayName'), + ), + ), + SizedBox(width: 12), + SizedBox(width: 4), + IconButton(icon: FaIcon(FontAwesomeIcons.solidFloppyDisk), onPressed: _saveDisplayName), + ], + ), + ), + ); + } else if (_editDisplayName == EditState.none) { + return UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidInputText, + title: 'DisplayName', + values: [_displayNameOverride ?? widget.channel.displayName], + iconActions: isOwned ? [(FontAwesomeIcons.penToSquare, _showEditDisplayName)] : [], + ); + } else if (_editDisplayName == EditState.saving) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0), + child: UI.box( + context: context, + padding: EdgeInsets.fromLTRB(16, 2, 4, 2), + child: Row( + children: [ + Container(child: Center(child: FaIcon(FontAwesomeIcons.solidInputText, size: 18)), height: 43), + SizedBox(width: 16), + Expanded(child: SizedBox()), + SizedBox(width: 12), + SizedBox(width: 4), + Padding(padding: const EdgeInsets.all(8.0), child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator())), + ], + ), + ), + ); + } else { + throw 'Invalid EditDisplayNameState: $_editDisplayName'; + } + } + + Widget _buildDescriptionNameCard(BuildContext context, bool isOwned) { + if (_editDescriptionName == EditState.editing) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0), + child: UI.box( + context: context, + padding: EdgeInsets.fromLTRB(16, 2, 4, 2), + child: Row( + children: [ + Container(child: Center(child: FaIcon(FontAwesomeIcons.solidInputPipe, size: 18)), height: 43), + SizedBox(width: 16), + Expanded( + child: TextField( + autofocus: true, + controller: _ctrlDescriptionName, + decoration: new InputDecoration.collapsed(hintText: 'Description'), + ), + ), + SizedBox(width: 12), + SizedBox(width: 4), + IconButton(icon: FaIcon(FontAwesomeIcons.solidFloppyDisk), onPressed: _saveDescriptionName), + ], + ), + ), + ); + } else if (_editDescriptionName == EditState.none) { + return UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidInputPipe, + title: 'Description', + values: [_descriptionNameOverride ?? widget.channel.descriptionName ?? ''], + iconActions: isOwned ? [(FontAwesomeIcons.penToSquare, _showEditDescriptionName)] : [], + ); + } else if (_editDescriptionName == EditState.saving) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0), + child: UI.box( + context: context, + padding: EdgeInsets.fromLTRB(16, 2, 4, 2), + child: Row( + children: [ + Container(child: Center(child: FaIcon(FontAwesomeIcons.solidInputPipe, size: 18)), height: 43), + SizedBox(width: 16), + Expanded(child: SizedBox()), + SizedBox(width: 12), + SizedBox(width: 4), + Padding(padding: const EdgeInsets.all(8.0), child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator())), + ], + ), + ), + ); + } else { + throw 'Invalid EditDescriptionNameState: $_editDescriptionName'; + } } void _subscribe() { @@ -237,6 +359,70 @@ class _ChannelViewPageState extends State { //TODO } + void _showEditDisplayName() { + setState(() { + _ctrlDisplayName.text = _displayNameOverride ?? widget.channel.displayName; + _editDisplayName = EditState.editing; + if (_editDescriptionName == EditState.editing) _editDescriptionName = EditState.none; + }); + } + + void _saveDisplayName() async { + final userAcc = Provider.of(context, listen: false); + + final newName = _ctrlDisplayName.text; + + try { + setState(() { + _editDisplayName = EditState.saving; + }); + + final newChannel = await APIClient.updateChannel(userAcc, widget.channel.channelID, displayName: newName); + + setState(() { + _editDisplayName = EditState.none; + _displayNameOverride = newChannel.channel.displayName; + }); + + widget.needsReload?.call(); + } catch (exc, trace) { + ApplicationLog.error('Failed to save DisplayName: ' + exc.toString(), trace: trace); + Toaster.error("Error", 'Failed to save DisplayName'); + } + } + + void _showEditDescriptionName() { + setState(() { + _ctrlDescriptionName.text = _descriptionNameOverride ?? widget.channel.descriptionName ?? ''; + _editDescriptionName = EditState.editing; + if (_editDisplayName == EditState.editing) _editDisplayName = EditState.none; + }); + } + + void _saveDescriptionName() async { + final userAcc = Provider.of(context, listen: false); + + final newName = _ctrlDescriptionName.text; + + try { + setState(() { + _editDescriptionName = EditState.saving; + }); + + final newChannel = await APIClient.updateChannel(userAcc, widget.channel.channelID, descriptionName: newName); + + setState(() { + _editDescriptionName = EditState.none; + _descriptionNameOverride = newChannel.channel.descriptionName ?? ''; + }); + + widget.needsReload?.call(); + } catch (exc, trace) { + ApplicationLog.error('Failed to save DescriptionName: ' + exc.toString(), trace: trace); + Toaster.error("Error", 'Failed to save DescriptionName'); + } + } + void _cancelForeignSubscription(Subscription sub) { //TODO } From 778451fa4c6323a25dd956e27dcc732e81872487 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Fri, 12 Jul 2024 23:08:56 +0200 Subject: [PATCH 13/16] channel list fixes --- .../lib/pages/channel_list/channel_list.dart | 36 ++++++++++++------- .../lib/pages/channel_view/channel_view.dart | 20 ++++++----- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/flutter/lib/pages/channel_list/channel_list.dart b/flutter/lib/pages/channel_list/channel_list.dart index e3323ba..f3149a8 100644 --- a/flutter/lib/pages/channel_list/channel_list.dart +++ b/flutter/lib/pages/channel_list/channel_list.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:provider/provider.dart'; import 'package:simplecloudnotifier/api/api_client.dart'; @@ -142,22 +143,31 @@ class _ChannelRootPageState extends State with RouteAware { @override Widget build(BuildContext context) { - return RefreshIndicator( - onRefresh: () => Future.sync( - () => _pagingController.refresh(), - ), - child: PagedListView( - pagingController: _pagingController, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) => ChannelListItem( - channel: item.channel, - subscription: item.subscription, - onPressed: () { - Navi.push(context, () => ChannelViewPage(channel: item.channel, subscription: item.subscription, needsReload: _enqueueReload)); - }, + return Scaffold( + body: RefreshIndicator( + onRefresh: () => Future.sync( + () => _pagingController.refresh(), + ), + child: PagedListView( + pagingController: _pagingController, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => ChannelListItem( + channel: item.channel, + subscription: item.subscription, + onPressed: () { + Navi.push(context, () => ChannelViewPage(channel: item.channel, subscription: item.subscription, needsReload: _enqueueReload)); + }, + ), ), ), ), + floatingActionButton: FloatingActionButton( + onPressed: () { + //TODO scan qr code to subscribe channel + }, + backgroundColor: , + child: const Icon(FontAwesomeIcons.qrcode), + ), ); } diff --git a/flutter/lib/pages/channel_view/channel_view.dart b/flutter/lib/pages/channel_view/channel_view.dart index 404fdbe..6b2a77e 100644 --- a/flutter/lib/pages/channel_view/channel_view.dart +++ b/flutter/lib/pages/channel_view/channel_view.dart @@ -37,7 +37,7 @@ enum EditState { none, editing, saving } class _ChannelViewPageState extends State { late ImmediateFuture _futureSubscribeKey; - late ImmediateFuture> _futureSubscriptions; + late ImmediateFuture> _futureSubscriptions; late ImmediateFuture _futureOwner; final TextEditingController _ctrlDisplayName = TextEditingController(); @@ -61,10 +61,10 @@ class _ChannelViewPageState extends State { } else { _futureSubscribeKey = ImmediateFuture.ofFuture(_getSubScribeKey(userAcc)); } - _futureSubscriptions = ImmediateFuture>.ofFuture(_listSubscriptions(userAcc)); + _futureSubscriptions = ImmediateFuture>.ofFuture(_listSubscriptions(userAcc)); } else { _futureSubscribeKey = ImmediateFuture.ofValue(null); - _futureSubscriptions = ImmediateFuture>.ofValue([]); + _futureSubscriptions = ImmediateFuture>.ofValue([]); } if (widget.channel.ownerUserID == userAcc.userID) { @@ -156,13 +156,13 @@ class _ChannelViewPageState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - for (final sub in snapshot.data!.where((sub) => sub.subscriptionID != widget.subscription?.subscriptionID)) + for (final (sub, user) in snapshot.data!.where((v) => v.$1.subscriptionID != widget.subscription?.subscriptionID)) UI.metaCard( context: context, icon: FontAwesomeIcons.solidDiagramSuccessor, - title: 'Subscription (other)', + title: 'Subscription (' + (user?.username ?? user?.userID ?? 'other') + ')', values: [_formatSubscriptionStatus(sub)], - iconActions: _getForignSubActions(sub), + iconActions: _getForeignSubActions(sub), ), ], ); @@ -461,7 +461,7 @@ class _ChannelViewPageState extends State { } } - Future> _listSubscriptions(AppAuth auth) async { + Future> _listSubscriptions(AppAuth auth) async { try { await Future.delayed(const Duration(seconds: 0), () {}); // this is annoyingly important - otherwise we call setLoadingIndeterminate directly in initStat() and get an exception.... @@ -469,9 +469,11 @@ class _ChannelViewPageState extends State { var subs = await APIClient.getChannelSubscriptions(auth, widget.channel.channelID); + var userMap = {for (var v in (await Future.wait(subs.map((e) => e.subscriberUserID).toSet().map((e) => APIClient.getUserPreview(auth, e)).toList()))) v.userID: v}; + //await Future.delayed(const Duration(seconds: 10), () {}); - return subs; + return subs.map((e) => (e, userMap[e.subscriberUserID] ?? null)).toList(); } finally { _incLoadingIndeterminateCounter(-1); } @@ -493,7 +495,7 @@ class _ChannelViewPageState extends State { } } - List<(IconData, void Function())> _getForignSubActions(Subscription sub) { + List<(IconData, void Function())> _getForeignSubActions(Subscription sub) { if (sub.confirmed) { return [(FontAwesomeIcons.solidSquareXmark, () => _cancelForeignSubscription(sub))]; } else { From be7035978b7423533420d9418c0eead1feb70f09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Sat, 13 Jul 2024 00:11:13 +0200 Subject: [PATCH 14/16] channel_message_list --- .../components/bottom_fab/fab_with_icons.dart | 81 -------- .../components/hidable_fab/hidable_fab.dart | 3 + flutter/lib/models/channel.dart | 10 + flutter/lib/nav_layout.dart | 1 + .../lib/pages/channel_list/channel_list.dart | 4 +- .../pages/channel_list/channel_list_item.dart | 16 +- .../channel_message_view.dart | 105 ++++++++++ .../lib/pages/channel_view/channel_view.dart | 180 ++++++++++++++---- .../lib/pages/message_view/message_view.dart | 8 +- 9 files changed, 281 insertions(+), 127 deletions(-) delete mode 100644 flutter/lib/components/bottom_fab/fab_with_icons.dart create mode 100644 flutter/lib/pages/channel_message_view/channel_message_view.dart diff --git a/flutter/lib/components/bottom_fab/fab_with_icons.dart b/flutter/lib/components/bottom_fab/fab_with_icons.dart deleted file mode 100644 index ff4735e..0000000 --- a/flutter/lib/components/bottom_fab/fab_with_icons.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:flutter/material.dart'; - -// https://stackoverflow.com/questions/46480221/flutter-floating-action-button-with-speed-dail - -class FabWithIcons extends StatefulWidget { - FabWithIcons({super.key, required this.icons, required this.onIconTapped}); - final List icons; - final ValueChanged onIconTapped; - - @override - State createState() => FabWithIconsState(); -} - -class FabWithIconsState extends State with TickerProviderStateMixin { - late AnimationController _controller; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 250), - ); - } - - @override - Widget build(BuildContext context) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: List.generate(widget.icons.length, (int index) { - return _buildChild(index); - }).toList() - ..add( - _buildFab(), - ), - ); - } - - Widget _buildChild(int index) { - Color backgroundColor = Theme.of(context).cardColor; - Color foregroundColor = Theme.of(context).secondaryHeaderColor; - return Container( - height: 70.0, - width: 56.0, - alignment: FractionalOffset.topCenter, - child: ScaleTransition( - scale: CurvedAnimation( - parent: _controller, - curve: Interval(0.0, 1.0 - index / widget.icons.length / 2.0, curve: Curves.easeOut), - ), - child: FloatingActionButton( - backgroundColor: backgroundColor, - mini: true, - child: Icon(widget.icons[index], color: foregroundColor), - onPressed: () => _onTapped(index), - ), - ), - ); - } - - Widget _buildFab() { - return FloatingActionButton( - onPressed: () { - if (_controller.isDismissed) { - _controller.forward(); - } else { - _controller.reverse(); - } - }, - tooltip: 'Increment', - elevation: 2.0, - child: const Icon(Icons.add), - ); - } - - void _onTapped(int index) { - _controller.reverse(); - widget.onIconTapped(index); - } -} diff --git a/flutter/lib/components/hidable_fab/hidable_fab.dart b/flutter/lib/components/hidable_fab/hidable_fab.dart index 3d0f577..fd7b4ef 100644 --- a/flutter/lib/components/hidable_fab/hidable_fab.dart +++ b/flutter/lib/components/hidable_fab/hidable_fab.dart @@ -3,17 +3,20 @@ import 'package:flutter/material.dart'; class HidableFAB extends StatelessWidget { final VoidCallback? onPressed; final IconData icon; + final Object heroTag; const HidableFAB({ super.key, this.onPressed, required this.icon, + required this.heroTag, }); Widget build(BuildContext context) { return Visibility( visible: MediaQuery.viewInsetsOf(context).bottom == 0.0, // hide when keyboard is shown child: FloatingActionButton( + heroTag: this.heroTag, onPressed: onPressed, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(17))), elevation: 2.0, diff --git a/flutter/lib/models/channel.dart b/flutter/lib/models/channel.dart index a2d1c6f..c912977 100644 --- a/flutter/lib/models/channel.dart +++ b/flutter/lib/models/channel.dart @@ -70,6 +70,16 @@ class Channel extends HiveObject implements FieldDebuggable { ('messagesSent', '${this.messagesSent}'), ]; } + + ChannelPreview toPreview() { + return ChannelPreview( + channelID: this.channelID, + ownerUserID: this.ownerUserID, + internalName: this.internalName, + displayName: this.displayName, + descriptionName: this.descriptionName, + ); + } } class ChannelWithSubscription { diff --git a/flutter/lib/nav_layout.dart b/flutter/lib/nav_layout.dart index cd9dafd..55e1687 100644 --- a/flutter/lib/nav_layout.dart +++ b/flutter/lib/nav_layout.dart @@ -76,6 +76,7 @@ class _SCNNavLayoutState extends State { bottomNavigationBar: _buildNavBar(context), floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, floatingActionButton: HidableFAB( + heroTag: 'fab_main', onPressed: _onFABTapped, icon: FontAwesomeIcons.solidPaperPlaneTop, ), diff --git a/flutter/lib/pages/channel_list/channel_list.dart b/flutter/lib/pages/channel_list/channel_list.dart index f3149a8..cf1e884 100644 --- a/flutter/lib/pages/channel_list/channel_list.dart +++ b/flutter/lib/pages/channel_list/channel_list.dart @@ -155,17 +155,17 @@ class _ChannelRootPageState extends State with RouteAware { channel: item.channel, subscription: item.subscription, onPressed: () { - Navi.push(context, () => ChannelViewPage(channel: item.channel, subscription: item.subscription, needsReload: _enqueueReload)); + Navi.push(context, () => ChannelViewPage(channelID: item.channel.channelID, preloadedData: (item.channel, item.subscription), needsReload: _enqueueReload)); }, ), ), ), ), floatingActionButton: FloatingActionButton( + heroTag: 'fab_channel_list_qr', onPressed: () { //TODO scan qr code to subscribe channel }, - backgroundColor: , child: const Icon(FontAwesomeIcons.qrcode), ), ); diff --git a/flutter/lib/pages/channel_list/channel_list_item.dart b/flutter/lib/pages/channel_list/channel_list_item.dart index 8dff3de..5f43148 100644 --- a/flutter/lib/pages/channel_list/channel_list_item.dart +++ b/flutter/lib/pages/channel_list/channel_list_item.dart @@ -1,4 +1,6 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; @@ -6,8 +8,11 @@ import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/scn_message.dart'; import 'package:simplecloudnotifier/models/subscription.dart'; +import 'package:simplecloudnotifier/pages/channel_message_view/channel_message_view.dart'; import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/scn_data_cache.dart'; +import 'package:simplecloudnotifier/utils/navi.dart'; +import 'package:simplecloudnotifier/utils/toaster.dart'; class ChannelListItem extends StatefulWidget { static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm'); @@ -56,7 +61,6 @@ class _ChannelListItemState extends State { shape: BeveledRectangleBorder(borderRadius: BorderRadius.circular(0)), color: Theme.of(context).cardTheme.color, child: InkWell( - splashColor: Theme.of(context).splashColor, onTap: widget.onPressed, child: Padding( padding: const EdgeInsets.all(8), @@ -98,6 +102,16 @@ class _ChannelListItemState extends State { ], ), ), + SizedBox(width: 4), + GestureDetector( + onTap: () { + Navi.push(context, () => ChannelMessageViewPage(channel: this.widget.channel)); + }, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon(FontAwesomeIcons.solidEnvelopes, color: Theme.of(context).colorScheme.onPrimaryContainer.withAlpha(128), size: 24), + ), + ), ], ), ), diff --git a/flutter/lib/pages/channel_message_view/channel_message_view.dart b/flutter/lib/pages/channel_message_view/channel_message_view.dart new file mode 100644 index 0000000..2146dac --- /dev/null +++ b/flutter/lib/pages/channel_message_view/channel_message_view.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; +import 'package:simplecloudnotifier/api/api_client.dart'; +import 'package:simplecloudnotifier/components/layout/scaffold.dart'; +import 'package:simplecloudnotifier/models/channel.dart'; +import 'package:simplecloudnotifier/models/scn_message.dart'; +import 'package:simplecloudnotifier/pages/message_list/message_list_item.dart'; +import 'package:simplecloudnotifier/pages/message_view/message_view.dart'; +import 'package:simplecloudnotifier/settings/app_settings.dart'; +import 'package:simplecloudnotifier/state/app_auth.dart'; +import 'package:simplecloudnotifier/state/application_log.dart'; +import 'package:simplecloudnotifier/state/scn_data_cache.dart'; +import 'package:simplecloudnotifier/utils/navi.dart'; +import 'package:provider/provider.dart'; + +class ChannelMessageViewPage extends StatefulWidget { + const ChannelMessageViewPage({ + required this.channel, + super.key, + }); + + final Channel channel; + + @override + State createState() => _ChannelMessageViewPageState(); +} + +class _ChannelMessageViewPageState extends State { + PagingController _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: '@start'); + + @override + void initState() { + super.initState(); + + _pagingController.addPageRequestListener(_fetchPage); + + _pagingController.refresh(); + } + + @override + void dispose() { + _pagingController.dispose(); + super.dispose(); + } + + Future _fetchPage(String thisPageToken) async { + final acc = Provider.of(context, listen: false); + final cfg = Provider.of(context, listen: false); + + ApplicationLog.debug('Start ChannelMessageViewPage::_pagingController::_fetchPage [ ${thisPageToken} ]'); + + if (!acc.isAuth()) { + _pagingController.error = 'Not logged in'; + return; + } + + try { + final (npt, newItems) = await APIClient.getMessageList(acc, thisPageToken, pageSize: cfg.messagePageSize, channelIDs: [this.widget.channel.channelID]); + + SCNDataCache().addToMessageCache(newItems); // no await + + if (npt == '@end') { + _pagingController.appendLastPage(newItems); + } else { + _pagingController.appendPage(newItems, npt); + } + } catch (exc, trace) { + _pagingController.error = exc.toString(); + ApplicationLog.error('Failed to list channel-messages: ' + exc.toString(), trace: trace); + } + } + + @override + Widget build(BuildContext context) { + return SCNScaffold( + title: this.widget.channel.displayName, + showSearch: false, + showShare: false, + child: _buildMessageList(context), + ); + } + + Widget _buildMessageList(BuildContext context) { + return Padding( + padding: EdgeInsets.fromLTRB(8, 4, 8, 4), + child: RefreshIndicator( + onRefresh: () => Future.sync( + () => _pagingController.refresh(), + ), + child: PagedListView( + pagingController: _pagingController, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => MessageListItem( + message: item, + allChannels: {this.widget.channel.channelID: this.widget.channel}, + onPressed: () { + Navi.push(context, () => MessageViewPage(message: item)); + }, + ), + ), + ), + ), + ); + } +} diff --git a/flutter/lib/pages/channel_view/channel_view.dart b/flutter/lib/pages/channel_view/channel_view.dart index 6b2a77e..7613c83 100644 --- a/flutter/lib/pages/channel_view/channel_view.dart +++ b/flutter/lib/pages/channel_view/channel_view.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:share_plus/share_plus.dart'; @@ -8,24 +7,26 @@ import 'package:simplecloudnotifier/components/layout/scaffold.dart'; import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/subscription.dart'; import 'package:simplecloudnotifier/models/user.dart'; +import 'package:simplecloudnotifier/pages/channel_message_view/channel_message_view.dart'; import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/application_log.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:provider/provider.dart'; class ChannelViewPage extends StatefulWidget { const ChannelViewPage({ - required this.channel, - required this.subscription, + required this.channelID, + required this.preloadedData, required this.needsReload, super.key, }); - final Channel channel; - final Subscription? subscription; + final String channelID; + final (Channel, Subscription?)? preloadedData; final void Function()? needsReload; @@ -35,6 +36,8 @@ class ChannelViewPage extends StatefulWidget { enum EditState { none, editing, saving } +enum ChannelViewPageInitState { loading, okay, error } + class _ChannelViewPageState extends State { late ImmediateFuture _futureSubscribeKey; late ImmediateFuture> _futureSubscriptions; @@ -51,15 +54,58 @@ class _ChannelViewPageState extends State { EditState _editDescriptionName = EditState.none; String? _descriptionNameOverride = null; + ChannelPreview? channelPreview; + Channel? channel; + Subscription? subscription; + + ChannelViewPageInitState loadingState = ChannelViewPageInitState.loading; + String errorMessage = ''; + @override void initState() { + _initStateAsync(); + + super.initState(); + } + + @override + void _initStateAsync() async { final userAcc = Provider.of(context, listen: false); - if (widget.channel.ownerUserID == userAcc.userID) { - if (widget.channel.subscribeKey != null) { - _futureSubscribeKey = ImmediateFuture.ofValue(widget.channel.subscribeKey); + if (widget.preloadedData != null) { + channelPreview = widget.preloadedData!.$1.toPreview(); + channel = widget.preloadedData!.$1; + subscription = widget.preloadedData!.$2; + } else { + try { + var p = await APIClient.getChannelPreview(userAcc, widget.channelID); + channelPreview = p; + if (p.ownerUserID == userAcc.userID) { + var r = await APIClient.getChannel(userAcc, widget.channelID); + channel = r.channel; + subscription = r.subscription; + } else { + channel = null; + subscription = null; //TODO get own subscription on this channel, even though its foreign channel + } + } catch (exc, trace) { + ApplicationLog.error('Failed to load data: ' + exc.toString(), trace: trace); + Toaster.error("Error", 'Failed to load data'); + this.errorMessage = 'Failed to load data: ' + exc.toString(); + this.loadingState = ChannelViewPageInitState.error; + return; + } + } + + this.loadingState = ChannelViewPageInitState.okay; + + assert(channelPreview != null); + + if (this.channelPreview!.ownerUserID == userAcc.userID) { + if (this.channel != null && this.channel!.subscribeKey != null) { + _futureSubscribeKey = ImmediateFuture.ofValue(this.channel!.subscribeKey); } else { - _futureSubscribeKey = ImmediateFuture.ofFuture(_getSubScribeKey(userAcc)); + _futureSubscribeKey = ImmediateFuture.ofFuture(_getSubscribeKey(userAcc)); } _futureSubscriptions = ImmediateFuture>.ofFuture(_listSubscriptions(userAcc)); } else { @@ -67,7 +113,7 @@ class _ChannelViewPageState extends State { _futureSubscriptions = ImmediateFuture>.ofValue([]); } - if (widget.channel.ownerUserID == userAcc.userID) { + if (this.channelPreview!.ownerUserID == userAcc.userID) { var cacheUser = userAcc.getUserOrNull(); if (cacheUser != null) { _futureOwner = ImmediateFuture.ofValue(cacheUser.toPreview()); @@ -75,10 +121,8 @@ class _ChannelViewPageState extends State { _futureOwner = ImmediateFuture.ofFuture(_getOwner(userAcc)); } } else { - _futureOwner = ImmediateFuture.ofFuture(APIClient.getUserPreview(userAcc, widget.channel.ownerUserID)); + _futureOwner = ImmediateFuture.ofFuture(APIClient.getUserPreview(userAcc, this.channelPreview!.ownerUserID)); } - - super.initState(); } @override @@ -90,19 +134,30 @@ class _ChannelViewPageState extends State { @override Widget build(BuildContext context) { + final userAcc = Provider.of(context, listen: false); + + Widget child; + + if (loadingState == ChannelViewPageInitState.loading) { + child = Center(child: CircularProgressIndicator()); + } else if (loadingState == ChannelViewPageInitState.error) { + child = Center(child: Text('Error: ' + errorMessage)); //TODO better error + } else if (loadingState == ChannelViewPageInitState.okay && channelPreview!.ownerUserID == userAcc.userID) { + child = _buildOwnedChannelView(context, this.channel!); + } else { + child = _buildForeignChannelView(context, this.channelPreview!); + } + return SCNScaffold( title: 'Channel', showSearch: false, showShare: false, - child: _buildChannelView(context), + child: child, ); } - Widget _buildChannelView(BuildContext context) { - final userAccUserID = context.select((v) => v.userID); - - final isOwned = (widget.channel.ownerUserID == userAccUserID); - final isSubscribed = (widget.subscription != null && widget.subscription!.confirmed); + Widget _buildOwnedChannelView(BuildContext context, Channel channel) { + final isSubscribed = (subscription != null && subscription!.confirmed); return SingleChildScrollView( child: Padding( @@ -116,31 +171,33 @@ class _ChannelViewPageState extends State { context: context, icon: FontAwesomeIcons.solidIdCardClip, title: 'ChannelID', - values: [widget.channel.channelID], + values: [channel.channelID], ), UI.metaCard( context: context, icon: FontAwesomeIcons.solidInputNumeric, title: 'InternalName', - values: [widget.channel.internalName], + values: [channel.internalName], ), - _buildDisplayNameCard(context, isOwned), - _buildDescriptionNameCard(context, isOwned), + _buildDisplayNameCard(context, true), + _buildDescriptionNameCard(context, true), UI.metaCard( context: context, icon: FontAwesomeIcons.solidDiagramSubtask, title: 'Subscription (own)', - values: [_formatSubscriptionStatus(widget.subscription)], + values: [_formatSubscriptionStatus(this.subscription)], iconActions: isSubscribed ? [(FontAwesomeIcons.solidSquareXmark, _unsubscribe)] : [(FontAwesomeIcons.solidSquareRss, _subscribe)], ), _buildForeignSubscriptions(context), - _buildOwnerCard(context, isOwned), + _buildOwnerCard(context, true), UI.metaCard( context: context, icon: FontAwesomeIcons.solidEnvelope, title: 'Messages', - values: [widget.channel.messagesSent.toString()], - mainAction: () {/*TODO*/}, + values: [channel.messagesSent.toString()], + mainAction: () { + Navi.push(context, () => ChannelMessageViewPage(channel: channel)); + }, ), ], ), @@ -148,6 +205,45 @@ class _ChannelViewPageState extends State { ); } + Widget _buildForeignChannelView(BuildContext context, ChannelPreview channel) { + final isSubscribed = (subscription != null && subscription!.confirmed); + + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(height: 8), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidIdCardClip, + title: 'ChannelID', + values: [channel.channelID], + ), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidInputNumeric, + title: 'InternalName', + values: [channel.internalName], + ), + _buildDisplayNameCard(context, false), + _buildDescriptionNameCard(context, false), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidDiagramSubtask, + title: 'Subscription (own)', + values: [_formatSubscriptionStatus(subscription)], + iconActions: isSubscribed ? [(FontAwesomeIcons.solidSquareXmark, _unsubscribe)] : [(FontAwesomeIcons.solidSquareRss, _subscribe)], + ), + _buildForeignSubscriptions(context), + _buildOwnerCard(context, false), + ], + ), + ), + ); + } + Widget _buildForeignSubscriptions(BuildContext context) { return FutureBuilder( future: _futureSubscriptions.future, @@ -156,7 +252,7 @@ class _ChannelViewPageState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - for (final (sub, user) in snapshot.data!.where((v) => v.$1.subscriptionID != widget.subscription?.subscriptionID)) + for (final (sub, user) in snapshot.data!.where((v) => v.$1.subscriptionID != subscription?.subscriptionID)) UI.metaCard( context: context, icon: FontAwesomeIcons.solidDiagramSuccessor, @@ -182,14 +278,14 @@ class _ChannelViewPageState extends State { context: context, icon: FontAwesomeIcons.solidUser, title: 'Owner', - values: [widget.channel.ownerUserID + (isOwned ? ' (you)' : ''), if (snapshot.data?.username != null) snapshot.data!.username!], + values: [channelPreview!.ownerUserID + (isOwned ? ' (you)' : ''), if (snapshot.data?.username != null) snapshot.data!.username!], ); } else { return UI.metaCard( context: context, icon: FontAwesomeIcons.solidUser, title: 'Owner', - values: [widget.channel.ownerUserID + (isOwned ? ' (you)' : '')], + values: [channelPreview!.ownerUserID + (isOwned ? ' (you)' : '')], ); } }, @@ -201,10 +297,10 @@ class _ChannelViewPageState extends State { future: _futureSubscribeKey.future, builder: (context, snapshot) { if (snapshot.hasData && snapshot.data != null) { - var text = 'TODO' + '\n' + widget.channel.channelID + '\n' + snapshot.data!; //TODO deeplink-y (also perhaps just bas64 everything together?) + var text = 'TODO' + '\n' + channel!.channelID + '\n' + snapshot.data!; //TODO deeplink-y (also perhaps just bas64 everything together?) return GestureDetector( onTap: () { - Share.share(text, subject: _displayNameOverride ?? widget.channel.displayName); + Share.share(text, subject: _displayNameOverride ?? channel!.displayName); }, child: Center( child: QrImageView( @@ -269,7 +365,7 @@ class _ChannelViewPageState extends State { context: context, icon: FontAwesomeIcons.solidInputText, title: 'DisplayName', - values: [_displayNameOverride ?? widget.channel.displayName], + values: [_displayNameOverride ?? channelPreview!.displayName], iconActions: isOwned ? [(FontAwesomeIcons.penToSquare, _showEditDisplayName)] : [], ); } else if (_editDisplayName == EditState.saving) { @@ -325,7 +421,7 @@ class _ChannelViewPageState extends State { context: context, icon: FontAwesomeIcons.solidInputPipe, title: 'Description', - values: [_descriptionNameOverride ?? widget.channel.descriptionName ?? ''], + values: [_descriptionNameOverride ?? channelPreview?.descriptionName ?? ''], iconActions: isOwned ? [(FontAwesomeIcons.penToSquare, _showEditDescriptionName)] : [], ); } else if (_editDescriptionName == EditState.saving) { @@ -361,7 +457,7 @@ class _ChannelViewPageState extends State { void _showEditDisplayName() { setState(() { - _ctrlDisplayName.text = _displayNameOverride ?? widget.channel.displayName; + _ctrlDisplayName.text = _displayNameOverride ?? channelPreview?.displayName ?? ''; _editDisplayName = EditState.editing; if (_editDescriptionName == EditState.editing) _editDescriptionName = EditState.none; }); @@ -377,7 +473,7 @@ class _ChannelViewPageState extends State { _editDisplayName = EditState.saving; }); - final newChannel = await APIClient.updateChannel(userAcc, widget.channel.channelID, displayName: newName); + final newChannel = await APIClient.updateChannel(userAcc, widget.channelID, displayName: newName); setState(() { _editDisplayName = EditState.none; @@ -393,7 +489,7 @@ class _ChannelViewPageState extends State { void _showEditDescriptionName() { setState(() { - _ctrlDescriptionName.text = _descriptionNameOverride ?? widget.channel.descriptionName ?? ''; + _ctrlDescriptionName.text = _descriptionNameOverride ?? channelPreview?.descriptionName ?? ''; _editDescriptionName = EditState.editing; if (_editDisplayName == EditState.editing) _editDisplayName = EditState.none; }); @@ -409,7 +505,7 @@ class _ChannelViewPageState extends State { _editDescriptionName = EditState.saving; }); - final newChannel = await APIClient.updateChannel(userAcc, widget.channel.channelID, descriptionName: newName); + final newChannel = await APIClient.updateChannel(userAcc, widget.channelID, descriptionName: newName); setState(() { _editDescriptionName = EditState.none; @@ -445,13 +541,13 @@ class _ChannelViewPageState extends State { } } - Future _getSubScribeKey(AppAuth auth) async { + Future _getSubscribeKey(AppAuth auth) async { try { await Future.delayed(const Duration(seconds: 0), () {}); // this is annoyingly important - otherwise we call setLoadingIndeterminate directly in initStat() and get an exception.... _incLoadingIndeterminateCounter(1); - var channel = await APIClient.getChannel(auth, widget.channel.channelID); + var channel = await APIClient.getChannel(auth, widget.channelID); //await Future.delayed(const Duration(seconds: 10), () {}); @@ -467,7 +563,7 @@ class _ChannelViewPageState extends State { _incLoadingIndeterminateCounter(1); - var subs = await APIClient.getChannelSubscriptions(auth, widget.channel.channelID); + var subs = await APIClient.getChannelSubscriptions(auth, widget.channelID); var userMap = {for (var v in (await Future.wait(subs.map((e) => e.subscriberUserID).toSet().map((e) => APIClient.getUserPreview(auth, e)).toList()))) v.userID: v}; @@ -485,7 +581,7 @@ class _ChannelViewPageState extends State { _incLoadingIndeterminateCounter(1); - final owner = APIClient.getUserPreview(auth, widget.channel.ownerUserID); + final owner = APIClient.getUserPreview(auth, channelPreview!.ownerUserID); //await Future.delayed(const Duration(seconds: 10), () {}); diff --git a/flutter/lib/pages/message_view/message_view.dart b/flutter/lib/pages/message_view/message_view.dart index 2877051..bd3463f 100644 --- a/flutter/lib/pages/message_view/message_view.dart +++ b/flutter/lib/pages/message_view/message_view.dart @@ -10,8 +10,10 @@ import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/keytoken.dart'; import 'package:simplecloudnotifier/models/scn_message.dart'; import 'package:simplecloudnotifier/models/user.dart'; +import 'package:simplecloudnotifier/pages/channel_view/channel_view.dart'; import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart'; +import 'package:simplecloudnotifier/utils/navi.dart'; import 'package:simplecloudnotifier/utils/toaster.dart'; import 'package:simplecloudnotifier/utils/ui.dart'; @@ -157,7 +159,11 @@ class _MessageViewPageState extends State { icon: FontAwesomeIcons.solidSnake, title: 'Channel', values: [message.channelID, channel?.displayName ?? message.channelInternalName], - mainAction: () => {/*TODO*/}, + mainAction: (channel != null) + ? () { + Navi.push(context, () => ChannelViewPage(channelID: channel.channelID, preloadedData: null, needsReload: null)); + } + : null, ), UI.metaCard( context: context, From 74a935f6f19d3bb88b524d156ec0adf8df5ab43a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Sat, 13 Jul 2024 00:16:01 +0200 Subject: [PATCH 15/16] Fix `scn-requests` box not being open in _onBackgroundMessage --- flutter/lib/main.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index 67e9dda..6feb2d1 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -302,6 +302,7 @@ Future _receiveMessage(RemoteMessage message, bool foreground) async { await Hive.openBox('scn-logs'); await Hive.openBox('scn-fb-messages'); await Hive.openBox('scn-message-cache'); + await Hive.openBox('scn-requests'); } catch (exc, trace) { ApplicationLog.error('Failed to init hive:' + exc.toString(), trace: trace); Notifier.showLocalNotification("@ERROR", "@ERROR", 'Error Channel', "Error", "Failed to receive SCN message (init failed)", null); From e93d1254315eff6d6915528c3e489dbd6e926351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Sat, 13 Jul 2024 01:05:32 +0200 Subject: [PATCH 16/16] Properly handle click actions on notifications --- flutter/TODO.md | 13 +++- flutter/lib/main.dart | 63 ++++++++++++++++--- .../channel_message_view.dart | 2 +- flutter/lib/pages/debug/debug_actions.dart | 2 +- .../lib/pages/message_list/message_list.dart | 2 +- .../lib/pages/message_view/message_view.dart | 25 ++++++-- flutter/lib/utils/notifier.dart | 15 ++++- 7 files changed, 103 insertions(+), 19 deletions(-) diff --git a/flutter/TODO.md b/flutter/TODO.md index e0903f8..ef21488 100644 --- a/flutter/TODO.md +++ b/flutter/TODO.md @@ -23,9 +23,20 @@ - [ ] Logout - [ ] Send-page - ----- +# TODO iOS specific + + - [ ] payment / pro + - [ ] show notifiactions (foreground/background/etc) + - [ ] handle click-on-notifications should open message + - [ ] share message + - [ ] scan QR + + ----- + +# TODO Server + - [ ] Switch server to sq style from faby - [ ] switch from mattn to go-sqlite - [ ] Single struct for model/db/json diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index 6feb2d1..5b552d4 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:provider/provider.dart'; import 'package:hive_flutter/hive_flutter.dart'; @@ -10,6 +11,8 @@ import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/client.dart'; import 'package:simplecloudnotifier/models/scn_message.dart'; import 'package:simplecloudnotifier/nav_layout.dart'; +import 'package:simplecloudnotifier/pages/channel_view/channel_view.dart'; +import 'package:simplecloudnotifier/pages/message_view/message_view.dart'; import 'package:simplecloudnotifier/settings/app_settings.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/app_events.dart'; @@ -174,12 +177,20 @@ void main() async { iOS: initializationSettingsDarwin, linux: initializationSettingsLinux, ); - flutterLocalNotificationsPlugin.initialize(initializationSettings, onDidReceiveNotificationResponse: _receiveLocalNotification); + flutterLocalNotificationsPlugin.initialize( + initializationSettings, + onDidReceiveNotificationResponse: _receiveLocalNotification, + onDidReceiveBackgroundNotificationResponse: _notificationTapBackground, + ); final appLaunchNotification = await flutterLocalNotificationsPlugin.getNotificationAppLaunchDetails(); if (appLaunchNotification != null) { - //TODO show message (also this only works on android+localnotifications, also handle ios) + // Use has launched SCN by clicking on a loca notifiaction, if it was a summary or message notifiaction open the corresponding screen + // This is android only + //TODO same on iOS, somehow?? ApplicationLog.info('App launched by notification: ${appLaunchNotification.notificationResponse?.id}'); + + _handleNotificationClickAction(appLaunchNotification.notificationResponse?.payload, Duration(milliseconds: 600)); } } @@ -201,6 +212,8 @@ void main() async { class SCNApp extends StatelessWidget { SCNApp({super.key}); + static var materialKey = GlobalKey(); + @override Widget build(BuildContext context) { return ToastificationWrapper( @@ -211,6 +224,7 @@ class SCNApp extends StatelessWidget { ), child: Consumer( builder: (context, appTheme, child) => MaterialApp( + navigatorKey: SCNApp.materialKey, title: 'SimpleCloudNotifier', navigatorObservers: [Navi.routeObserver, Navi.modalRouteObserver], theme: ThemeData( @@ -226,7 +240,8 @@ class SCNApp extends StatelessWidget { } @pragma('vm:entry-point') -void notificationTapBackground(NotificationResponse notificationResponse) { +void _notificationTapBackground(NotificationResponse notificationResponse) { + // I think only iOS triggers this, TODO ApplicationLog.info('Received local notification: ${notificationResponse.id}'); } @@ -268,11 +283,13 @@ void setFirebaseToken(String fcmToken) async { @pragma('vm:entry-point') Future _onBackgroundMessage(RemoteMessage message) async { + // a firebase message was received while the app was in the background or terminated await _receiveMessage(message, false); } @pragma('vm:entry-point') void _onForegroundMessage(RemoteMessage message) { + // a firebase message was received while the app was in the foreground _receiveMessage(message, true); } @@ -305,7 +322,7 @@ Future _receiveMessage(RemoteMessage message, bool foreground) async { await Hive.openBox('scn-requests'); } catch (exc, trace) { ApplicationLog.error('Failed to init hive:' + exc.toString(), trace: trace); - Notifier.showLocalNotification("@ERROR", "@ERROR", 'Error Channel', "Error", "Failed to receive SCN message (init failed)", null); + Notifier.showLocalNotification("", "@ERROR", "@ERROR", 'Error Channel', "Error", "Failed to receive SCN message (init failed)", null); return; } @@ -322,10 +339,10 @@ Future _receiveMessage(RemoteMessage message, bool foreground) async { final channel_id = message.data['channel_id'] as String; final body = message.data['body'] as String; - Notifier.showLocalNotification(channel_id, channel, 'Channel: ${channel}', title, body, timestamp); + Notifier.showLocalNotification(scn_msg_id, channel_id, channel, 'Channel: ${channel}', title, body, timestamp); } catch (exc, trace) { ApplicationLog.error('Failed to decode received FB message' + exc.toString(), trace: trace); - Notifier.showLocalNotification("@ERROR", "@ERROR", 'Error Channel', "Error", "Failed to receive SCN message (decode failed)", null); + Notifier.showLocalNotification("", "@ERROR", "@ERROR", 'Error Channel', "Error", "Failed to receive SCN message (decode failed)", null); return; } @@ -333,7 +350,7 @@ Future _receiveMessage(RemoteMessage message, bool foreground) async { FBMessageLog.insert(message); } catch (exc, trace) { ApplicationLog.error('Failed to persist received FB message' + exc.toString(), trace: trace); - Notifier.showLocalNotification("@ERROR", "@ERROR", 'Error Channel', "Error", "Failed to receive SCN message (persist failed)", null); + Notifier.showLocalNotification("", "@ERROR", "@ERROR", 'Error Channel', "Error", "Failed to receive SCN message (persist failed)", null); return; } @@ -348,11 +365,41 @@ Future _receiveMessage(RemoteMessage message, bool foreground) async { } void _receiveLocalDarwinNotification(int id, String? title, String? body, String? payload) { + //TODO iOS? ApplicationLog.info('Received local notification: $id -> [$title]'); } void _receiveLocalNotification(NotificationResponse details) { - ApplicationLog.info('Received local notification: ${details.id}'); + // User has tapped a flutter_local notification, while the app was running + ApplicationLog.info('Tapped local notification: [[${details.id} | ${details.actionId} | ${details.input} | ${details.notificationResponseType} | ${details.payload}]]'); + + _handleNotificationClickAction(details.payload, Duration.zero); +} + +void _handleNotificationClickAction(String? payload, Duration delay) { + final parts = payload?.split('\n') ?? []; + + if (parts.length == 4 && parts[0] == '@SCN_MESSAGE') { + final messageID = parts[1]; + () async { + await Future.delayed(delay); + + SchedulerBinding.instance.addPostFrameCallback((_) { + ApplicationLog.info('Handle notification action @SCN_MESSAGE --> ${messageID}'); + Navi.push(SCNApp.materialKey.currentContext!, () => MessageViewPage(messageID: messageID, preloadedData: null)); + }); + }(); + } else if (parts.length == 3 && parts[0] == '@SCN_MESSAGE_SUMMARY') { + final channelID = parts[1]; + () async { + await Future.delayed(delay); + + SchedulerBinding.instance.addPostFrameCallback((_) { + ApplicationLog.info('Handle notification action @SCN_MESSAGE_SUMMARY --> ${channelID}'); + Navi.push(SCNApp.materialKey.currentContext!, () => ChannelViewPage(channelID: channelID, preloadedData: null, needsReload: null)); + }); + }(); + } } List getDarwinNotificationCategories() { diff --git a/flutter/lib/pages/channel_message_view/channel_message_view.dart b/flutter/lib/pages/channel_message_view/channel_message_view.dart index 2146dac..42a9068 100644 --- a/flutter/lib/pages/channel_message_view/channel_message_view.dart +++ b/flutter/lib/pages/channel_message_view/channel_message_view.dart @@ -94,7 +94,7 @@ class _ChannelMessageViewPageState extends State { message: item, allChannels: {this.widget.channel.channelID: this.widget.channel}, onPressed: () { - Navi.push(context, () => MessageViewPage(message: item)); + Navi.push(context, () => MessageViewPage(messageID: item.messageID, preloadedData: (item,))); }, ), ), diff --git a/flutter/lib/pages/debug/debug_actions.dart b/flutter/lib/pages/debug/debug_actions.dart index e658e0e..aaef251 100644 --- a/flutter/lib/pages/debug/debug_actions.dart +++ b/flutter/lib/pages/debug/debug_actions.dart @@ -56,7 +56,7 @@ class _DebugActionsPageState extends State { SizedBox(height: 20), UI.button( big: false, - onPressed: () => Notifier.showLocalNotification('TEST_CHANNEL', "Test Channel", "Channel for testing", "Hello World", "Local Notification test", null), + onPressed: () => Notifier.showLocalNotification('', 'TEST_CHANNEL', "Test Channel", "Channel for testing", "Hello World", "Local Notification test", null), text: 'Show local notification', ), ], diff --git a/flutter/lib/pages/message_list/message_list.dart b/flutter/lib/pages/message_list/message_list.dart index bbe4cb0..cc39226 100644 --- a/flutter/lib/pages/message_list/message_list.dart +++ b/flutter/lib/pages/message_list/message_list.dart @@ -249,7 +249,7 @@ class _MessageListPageState extends State with RouteAware { message: item, allChannels: _channels ?? {}, onPressed: () { - Navi.push(context, () => MessageViewPage(message: item)); + Navi.push(context, () => MessageViewPage(messageID: item.messageID, preloadedData: (item,))); }, ), ), diff --git a/flutter/lib/pages/message_view/message_view.dart b/flutter/lib/pages/message_view/message_view.dart index bd3463f..57f8a2c 100644 --- a/flutter/lib/pages/message_view/message_view.dart +++ b/flutter/lib/pages/message_view/message_view.dart @@ -18,9 +18,14 @@ import 'package:simplecloudnotifier/utils/toaster.dart'; import 'package:simplecloudnotifier/utils/ui.dart'; class MessageViewPage extends StatefulWidget { - const MessageViewPage({super.key, required this.message}); + const MessageViewPage({ + super.key, + required this.messageID, + required this.preloadedData, + }); - final SCNMessage message; // Potentially trimmed + final String messageID; // Potentially trimmed + final (SCNMessage,)? preloadedData; // Message is potentially trimmed, whole object is potentially null @override State createState() => _MessageViewPageState(); @@ -33,8 +38,14 @@ class _MessageViewPageState extends State { bool _monospaceMode = false; + SCNMessage? message = null; + @override void initState() { + if (widget.preloadedData != null) { + message = widget.preloadedData!.$1; + } + mainFuture = fetchData(); super.initState(); } @@ -47,7 +58,7 @@ class _MessageViewPageState extends State { final acc = Provider.of(context, listen: false); - final msg = await APIClient.getMessage(acc, widget.message.messageID); + final msg = await APIClient.getMessage(acc, widget.messageID); final fut_chn = APIClient.getChannelPreview(acc, msg.channelID); final fut_key = APIClient.getKeyTokenPreview(acc, msg.usedKeyID); @@ -89,8 +100,8 @@ class _MessageViewPageState extends State { 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, null); + } else if (message != null && !this.message!.trimmed) { + return _buildMessageView(context, this.message!, null, null, null); } else { return const Center(child: CircularProgressIndicator()); } @@ -100,7 +111,9 @@ class _MessageViewPageState extends State { } void _share() async { - var msg = widget.message; + if (this.message == null) return; + + var msg = this.message!; if (mainFutureSnapshot != null) { (msg, _, _, _) = mainFutureSnapshot!; } diff --git a/flutter/lib/utils/notifier.dart b/flutter/lib/utils/notifier.dart index 0abb3ce..80c7050 100644 --- a/flutter/lib/utils/notifier.dart +++ b/flutter/lib/utils/notifier.dart @@ -6,7 +6,7 @@ import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/globals.dart'; class Notifier { - static void showLocalNotification(String channelID, String channelName, String channelDescr, String title, String body, DateTime? timestamp) async { + static void showLocalNotification(String messageID, String channelID, String channelName, String channelDescr, String title, String body, DateTime? timestamp) async { final nid = Globals().sharedPrefs.getInt('notifier.nextid') ?? 1000; Globals().sharedPrefs.setInt('notifier.nextid', nid + 7); @@ -25,6 +25,12 @@ class Notifier { final newSummaryNID = nid + 1; ApplicationLog.debug('Create new summary notifications for channel ${channelID} with nid [${newSummaryNID}])'); Globals().sharedPrefs.setInt('notifier.summary.$channelID', newSummaryNID); + + var payload = ''; + if (messageID != '') { + payload = ['@SCN_MESSAGE_SUMMARY', channelID, newSummaryNID].join("\n"); + } + await flutterLocalNotificationsPlugin.show( newSummaryNID, channelName, @@ -40,6 +46,7 @@ class Notifier { subText: (channelName == 'main') ? null : channelName, ), ), + payload: payload, ); } } @@ -48,6 +55,11 @@ class Notifier { ApplicationLog.debug('Create new local notifications for message in channel ${channelID} with nid [${newMessageNID}])'); + var payload = ''; + if (messageID != '') { + payload = ['@SCN_MESSAGE', messageID, channelID, newMessageNID].join("\n"); + } + // ======== SHOW NOTIFICATION ======== await flutterLocalNotificationsPlugin.show( newMessageNID, @@ -65,6 +77,7 @@ class Notifier { subText: (channelName == 'main') ? null : channelName, ), ), + payload: payload, ); } }