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 @@ + + + + + + + + + + + + + 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 0000000..125fc41 Binary files /dev/null and b/flutter/android/app/src/main/res/drawable-xhdpi/ic_notification_white.png differ diff --git a/flutter/android/app/src/main/res/drawable-xxhdpi/ic_notification_white.png b/flutter/android/app/src/main/res/drawable-xxhdpi/ic_notification_white.png new file mode 100644 index 0000000..862d409 Binary files /dev/null and b/flutter/android/app/src/main/res/drawable-xxhdpi/ic_notification_white.png differ diff --git a/flutter/android/app/src/main/res/drawable-xxxhdpi/ic_notification_white.png b/flutter/android/app/src/main/res/drawable-xxxhdpi/ic_notification_white.png new file mode 100644 index 0000000..fe0e47b Binary files /dev/null and b/flutter/android/app/src/main/res/drawable-xxxhdpi/ic_notification_white.png differ 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..8066b79 100644 --- a/flutter/lib/api/api_client.dart +++ b/flutter/lib/api/api_client.dart @@ -7,11 +7,12 @@ 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'; 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 +212,21 @@ class APIClient { ); } - static Future<(String, List)> getMessageList(TokenSource auth, String pageToken, {int? pageSize, List? channelIDs}) async { + 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', method: 'GET', @@ -221,18 +236,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(), ); } @@ -247,6 +262,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/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/components/layout/app_bar.dart b/flutter/lib/components/layout/app_bar.dart index 165e0fc..b171c7a 100644 --- a/flutter/lib/components/layout/app_bar.dart +++ b/flutter/lib/components/layout/app_bar.dart @@ -1,18 +1,20 @@ 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_events.dart'; import 'package:simplecloudnotifier/state/app_theme.dart'; import 'package:simplecloudnotifier/utils/navi.dart'; -class SCNAppBar extends StatelessWidget implements PreferredSizeWidget { - const SCNAppBar({ +class SCNAppBar extends StatefulWidget implements PreferredSizeWidget { + SCNAppBar({ Key? key, required this.title, required this.showThemeSwitch, - required this.showDebug, required this.showSearch, required this.showShare, this.onShare = null, @@ -20,16 +22,33 @@ 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 + 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); + var actions = []; - if (showDebug) { + if (cfg.showDebugButton) { actions.add(IconButton( icon: const Icon(FontAwesomeIcons.solidSpiderBlackWidow), tooltip: 'Debug', @@ -39,7 +58,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 +67,116 @@ 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()); + 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); + AppEvents().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); + AppEvents().notifySearchListeners(_ctrlSearchField.text); + _ctrlSearchField.clear(); + }, + ); + } + + void _showFilterDialog(BuildContext context) { + 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/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..5b552d4 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -2,14 +2,20 @@ 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'; 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/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'; import 'package:simplecloudnotifier/state/app_theme.dart'; import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/fb_message.dart'; @@ -17,7 +23,9 @@ 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'; import 'firebase_options.dart'; @@ -39,20 +47,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 +61,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); } @@ -98,20 +106,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) { @@ -147,6 +154,46 @@ 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, + onDidReceiveBackgroundNotificationResponse: _notificationTapBackground, + ); + + final appLaunchNotification = await flutterLocalNotificationsPlugin.getNotificationAppLaunchDetails(); + if (appLaunchNotification != null) { + // 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)); + } + } + ApplicationLog.debug('[INIT] Application started'); runApp( @@ -155,6 +202,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(), ), @@ -164,6 +212,8 @@ void main() async { class SCNApp extends StatelessWidget { SCNApp({super.key}); + static var materialKey = GlobalKey(); + @override Widget build(BuildContext context) { return ToastificationWrapper( @@ -174,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( @@ -188,6 +239,12 @@ class SCNApp extends StatelessWidget { } } +@pragma('vm:entry-point') +void _notificationTapBackground(NotificationResponse notificationResponse) { + // I think only iOS triggers this, TODO + ApplicationLog.info('Received local notification: ${notificationResponse.id}'); +} + void setFirebaseToken(String fcmToken) async { final acc = AppAuth(); @@ -224,18 +281,129 @@ 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); } 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'); + 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); + return; + } ApplicationLog.info('Received FB message (${foreground ? 'foreground' : 'background'}): ${message.messageId ?? 'NULL'}'); - FBMessageLog.insert(message); + + String scn_msg_id; + + try { + scn_msg_id = message.data['scn_msg_id'] as String; + + final timestamp = DateTime.fromMillisecondsSinceEpoch(int.parse(message.data['timestamp'] as String) * 1000); + 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(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); + 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; + } + + 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) { + //TODO iOS? + ApplicationLog.info('Received local notification: $id -> [$title]'); +} + +void _receiveLocalNotification(NotificationResponse details) { + // 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() { + return [ + //TODO ?!? + ]; } diff --git a/flutter/lib/models/channel.dart b/flutter/lib/models/channel.dart index 9588fa4..c912977 100644 --- a/flutter/lib/models/channel.dart +++ b/flutter/lib/models/channel.dart @@ -70,11 +70,21 @@ 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 { final Channel channel; - final Subscription subscription; + final Subscription? subscription; ChannelWithSubscription({ required this.channel, @@ -84,7 +94,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/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/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/models/user.dart b/flutter/lib/models/user.dart index 58bd769..f0bd9cb 100644 --- a/flutter/lib/models/user.dart +++ b/flutter/lib/models/user.dart @@ -63,6 +63,33 @@ 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, + }; + } + + UserPreview toPreview() { + return UserPreview(userID: userID, username: username); + } } class UserWithClientsAndKeys { diff --git a/flutter/lib/nav_layout.dart b/flutter/lib/nav_layout.dart index b5265e7..55e1687 100644 --- a/flutter/lib/nav_layout.dart +++ b/flutter/lib/nav_layout.dart @@ -59,8 +59,7 @@ class _SCNNavLayoutState extends State { return Scaffold( appBar: SCNAppBar( title: null, - showDebug: true, - showSearch: _selectedIndex == 0 || _selectedIndex == 1, + showSearch: _selectedIndex == 0, showShare: false, showThemeSwitch: true, ), @@ -77,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 fa0455f..cf1e884 100644 --- a/flutter/lib/pages/channel_list/channel_list.dart +++ b/flutter/lib/pages/channel_list/channel_list.dart @@ -1,12 +1,15 @@ 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'; 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}); @@ -17,11 +20,13 @@ class ChannelRootPage extends StatefulWidget { State createState() => _ChannelRootPageState(); } -class _ChannelRootPageState extends State { - final PagingController _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: 0); +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(); @@ -31,10 +36,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(); } @@ -51,6 +63,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(); @@ -68,9 +98,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,13 +124,17 @@ 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); + 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); @@ -109,19 +143,35 @@ class _ChannelRootPageState extends State { @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, - onPressed: () {/*TODO*/}, + 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(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 + }, + child: const Icon(FontAwesomeIcons.qrcode), + ), ); } + + void _enqueueReload() { + _reloadEnqueued = 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..5f43148 100644 --- a/flutter/lib/pages/channel_list/channel_list_item.dart +++ b/flutter/lib/pages/channel_list/channel_list_item.dart @@ -1,10 +1,18 @@ +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'; 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/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'); @@ -12,10 +20,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 @@ -23,7 +33,7 @@ class ChannelListItem extends StatefulWidget { } class _ChannelListItemState extends State { - Message? lastMessage; + SCNMessage? lastMessage; @override void initState() { @@ -32,6 +42,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(() { @@ -49,39 +61,56 @@ 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), - 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)), + ), + ), + Text(widget.channel.messagesSent.toString(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), + ], + ), + ], + ), ), - SizedBox(height: 4), - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: Text( - lastMessage?.title ?? '...', - style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160)), - ), - ), - Text(widget.channel.messagesSent.toString(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), - ], + 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), + ), ), ], ), @@ -89,4 +118,21 @@ class _ChannelListItemState extends State { ), ); } + + String _preformatTitle(SCNMessage? message) { + 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 && 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.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_message_view/channel_message_view.dart b/flutter/lib/pages/channel_message_view/channel_message_view.dart new file mode 100644 index 0000000..42a9068 --- /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(messageID: item.messageID, preloadedData: (item,))); + }, + ), + ), + ), + ), + ); + } +} 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..7613c83 --- /dev/null +++ b/flutter/lib/pages/channel_view/channel_view.dart @@ -0,0 +1,611 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.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/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.channelID, + required this.preloadedData, + required this.needsReload, + super.key, + }); + + final String channelID; + final (Channel, Subscription?)? preloadedData; + + final void Function()? needsReload; + + @override + State createState() => _ChannelViewPageState(); +} + +enum EditState { none, editing, saving } + +enum ChannelViewPageInitState { loading, okay, error } + +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; + + 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.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)); + } + _futureSubscriptions = ImmediateFuture>.ofFuture(_listSubscriptions(userAcc)); + } else { + _futureSubscribeKey = ImmediateFuture.ofValue(null); + _futureSubscriptions = ImmediateFuture>.ofValue([]); + } + + if (this.channelPreview!.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, this.channelPreview!.ownerUserID)); + } + } + + @override + void dispose() { + _ctrlDisplayName.dispose(); + _ctrlDescriptionName.dispose(); + super.dispose(); + } + + @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: child, + ); + } + + Widget _buildOwnedChannelView(BuildContext context, Channel 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: [ + _buildQRCode(context), + 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, true), + _buildDescriptionNameCard(context, true), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidDiagramSubtask, + title: 'Subscription (own)', + values: [_formatSubscriptionStatus(this.subscription)], + iconActions: isSubscribed ? [(FontAwesomeIcons.solidSquareXmark, _unsubscribe)] : [(FontAwesomeIcons.solidSquareRss, _subscribe)], + ), + _buildForeignSubscriptions(context), + _buildOwnerCard(context, true), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidEnvelope, + title: 'Messages', + values: [channel.messagesSent.toString()], + mainAction: () { + Navi.push(context, () => ChannelMessageViewPage(channel: channel)); + }, + ), + ], + ), + ), + ); + } + + 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, + builder: (context, snapshot) { + if (snapshot.hasData) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final (sub, user) in snapshot.data!.where((v) => v.$1.subscriptionID != subscription?.subscriptionID)) + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidDiagramSuccessor, + title: 'Subscription (' + (user?.username ?? user?.userID ?? 'other') + ')', + values: [_formatSubscriptionStatus(sub)], + iconActions: _getForeignSubActions(sub), + ), + ], + ); + } else { + return SizedBox(); + } + }, + ); + } + + 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: [channelPreview!.ownerUserID + (isOwned ? ' (you)' : ''), if (snapshot.data?.username != null) snapshot.data!.username!], + ); + } else { + return UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidUser, + title: 'Owner', + values: [channelPreview!.ownerUserID + (isOwned ? ' (you)' : '')], + ); + } + }, + ); + } + + Widget _buildQRCode(BuildContext context) { + return FutureBuilder( + future: _futureSubscribeKey.future, + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data != null) { + 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 ?? 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()), + ); + } + }, + ); + } + + 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 ?? channelPreview!.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 ?? channelPreview?.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() { + //TODO + } + + void _unsubscribe() { + //TODO + } + + void _showEditDisplayName() { + setState(() { + _ctrlDisplayName.text = _displayNameOverride ?? channelPreview?.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.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 ?? channelPreview?.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.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 + } + + 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.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.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.map((e) => (e, userMap[e.subscriberUserID] ?? null)).toList(); + } 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, channelPreview!.ownerUserID); + + //await Future.delayed(const Duration(seconds: 10), () {}); + + return owner; + } finally { + _incLoadingIndeterminateCounter(-1); + } + } + + List<(IconData, void Function())> _getForeignSubActions(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/debug/debug_actions.dart b/flutter/lib/pages/debug/debug_actions.dart index 79d0937..aaef251 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_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 8e91212..cc39226 100644 --- a/flutter/lib/pages/message_list/message_list.dart +++ b/flutter/lib/pages/message_list/message_list.dart @@ -1,15 +1,18 @@ import 'package:flutter/material.dart'; -import 'package:hive_flutter/hive_flutter.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:provider/provider.dart'; import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/models/channel.dart'; -import 'package:simplecloudnotifier/models/message.dart'; +import 'package:simplecloudnotifier/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'; +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 { @@ -17,28 +20,28 @@ class MessageListPage extends StatefulWidget { final bool isVisiblePage; - //TODO reload on switch to tab - //TODO reload on app to foreground - @override State createState() => _MessageListPageState(); } 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; bool _isInitialized = false; + List _filterChiplets = []; + @override void initState() { super.initState(); + AppEvents().subscribeSearchListener(_onAppBarSearch); + AppEvents().subscribeMessageReceivedListener(_onMessageReceivedViaNotification); + _pagingController.addPageRequestListener(_fetchPage); if (widget.isVisiblePage && !_isInitialized) _realInitState(); @@ -64,18 +67,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 { @@ -95,6 +92,8 @@ class _MessageListPageState extends State with RouteAware { @override void dispose() { ApplicationLog.debug('MessageListPage::dispose'); + AppEvents().unsubscribeSearchListener(_onAppBarSearch); + AppEvents().unsubscribeMessageReceivedListener(_onMessageReceivedViaNotification); Navi.modalRouteObserver.unsubscribe(this); _pagingController.dispose(); _lifecyleListener.dispose(); @@ -108,17 +107,22 @@ class _MessageListPageState extends State with RouteAware { @override void didPopNext() { - ApplicationLog.debug('[MessageList::RouteObserver] --> didPopNext (will background-refresh)'); - _backgroundRefresh(false); + 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 && widget.isVisiblePage) { + ApplicationLog.debug('[MessageList::_onLifecycleResume] --> (will background-refresh)'); + _backgroundRefresh(false); + } } 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} ]'); @@ -132,12 +136,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: _pageSize); + 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} ]'); @@ -154,6 +158,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)'); @@ -167,12 +172,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: _pageSize); + 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 @@ -221,49 +226,63 @@ 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(messageID: item.messageID, preloadedData: (item,))); + }, + ), + ), + ), ), ), - ), + ], ), ); } - 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); + 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), + ), + ); } - Future _addToMessageCache(List newItems) async { - final cache = Hive.box('scn-message-cache'); + void _onAppBarSearch(String str) { + setState(() { + _filterChiplets = _filterChiplets.where((element) => false).toList() + [MessageFilterChiplet(label: str, value: str, type: MessageFilterChipletType.search)]; + }); + } - for (var msg in newItems) await cache.put(msg.messageID, msg); - - // delete all but the newest 128 messages - - if (cache.length < _pageSize) return; - - final allValues = cache.values.toList(); - - allValues.sort((a, b) => -1 * a.timestamp.compareTo(b.timestamp)); - - for (var val in allValues.sublist(_pageSize)) { - await cache.delete(val.messageID); - } + void _onMessageReceivedViaNotification(SCNMessage msg) { + setState(() { + _pagingController.itemList = [msg] + (_pagingController.itemList ?? []); + }); } } 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..57f8a2c 100644 --- a/flutter/lib/pages/message_view/message_view.dart +++ b/flutter/lib/pages/message_view/message_view.dart @@ -8,36 +8,49 @@ 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/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'; class MessageViewPage extends StatefulWidget { - const MessageViewPage({super.key, required this.message}); + const MessageViewPage({ + super.key, + required this.messageID, + required this.preloadedData, + }); - final Message message; // Potentially trimmed + final String messageID; // Potentially trimmed + final (SCNMessage,)? preloadedData; // Message is potentially trimmed, whole object is potentially null @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; + SCNMessage? message = null; + @override void initState() { + if (widget.preloadedData != null) { + message = widget.preloadedData!.$1; + } + mainFuture = fetchData(); 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.... @@ -45,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); @@ -79,7 +92,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) { @@ -87,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()); } @@ -98,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!; } @@ -118,7 +133,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( @@ -131,12 +146,58 @@ 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, 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 + 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: (channel != null) + ? () { + Navi.push(context, () => ChannelViewPage(channelID: channel.channelID, preloadedData: null, needsReload: null)); + } + : null, + ), + 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]), ], ), @@ -144,11 +205,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 +228,7 @@ class _MessageViewPageState extends State { ]; } - List _buildMessageContent(BuildContext context, Message message) { + List _buildMessageContent(BuildContext context, SCNMessage message) { return [ Row( children: [ @@ -213,43 +274,20 @@ 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(Message message) { + 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/settings/app_settings.dart b/flutter/lib/settings/app_settings.dart new file mode 100644 index 0000000..424ce06 --- /dev/null +++ b/flutter/lib/settings/app_settings.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +class AppSettings extends ChangeNotifier { + bool groupNotifications = true; + int messagePageSize = 128; + bool showDebugButton = true; + bool backgroundRefreshMessageListOnPop = false; + bool alwaysBackgroundRefreshMessageListOnLifecycleResume = 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/app_auth.dart b/flutter/lib/state/app_auth.dart index a161485..038feed 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,20 +175,24 @@ class AppAuth extends ChangeNotifier implements TokenSource { final user = await APIClient.getUser(this, _userID!); - _user = user; + _user = (user, DateTime.now()); await save(); return user; } + User? getUserOrNull() { + return _user?.$1; + } + 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 +202,7 @@ class AppAuth extends ChangeNotifier implements TokenSource { try { final client = await APIClient.getClient(this, _clientID!); - _client = client; + _client = (client, DateTime.now()); await save(); @@ -154,6 +216,10 @@ class AppAuth extends ChangeNotifier implements TokenSource { } } + Client? getClientOrNull() { + return _client?.$1; + } + @override String getToken() { return _tokenAdmin!; diff --git a/flutter/lib/state/app_bar_state.dart b/flutter/lib/state/app_bar_state.dart index b62384a..e1c6a32 100644 --- a/flutter/lib/state/app_bar_state.dart +++ b/flutter/lib/state/app_bar_state.dart @@ -12,9 +12,18 @@ class AppBarState extends ChangeNotifier { 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(); + } } 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/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..ea913c1 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..004fd2a 100644 --- a/flutter/lib/state/globals.dart +++ b/flutter/lib/state/globals.dart @@ -13,6 +13,8 @@ class Globals { Globals._internal(); + bool _initialized = false; + String appName = ''; String packageName = ''; String version = ''; @@ -24,7 +26,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 +60,8 @@ class Globals { } this.sharedPrefs = await SharedPreferences.getInstance(); + + this._initialized = true; } String? getPrefFCMToken() { 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; + } +} 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()'); } diff --git a/flutter/lib/utils/notifier.dart b/flutter/lib/utils/notifier.dart new file mode 100644 index 0000000..80c7050 --- /dev/null +++ b/flutter/lib/utils/notifier.dart @@ -0,0 +1,83 @@ +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 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); + + 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); + + var payload = ''; + if (messageID != '') { + payload = ['@SCN_MESSAGE_SUMMARY', channelID, newSummaryNID].join("\n"); + } + + 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, + ), + ), + payload: payload, + ); + } + } + + final newMessageNID = nid + 2; + + 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, + 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, + ), + ), + payload: payload, + ); + } +} 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, + ), + ); + } + } } 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: