From 0bbe5fc7fa1d3055b4e7d4e69484529dbf420a6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Sun, 16 Jun 2024 00:46:46 +0200 Subject: [PATCH] Working on message search+filter --- flutter/lib/components/layout/app_bar.dart | 155 ++++++++++++++---- .../layout/app_bar_filter_dialog.dart | 86 ++++++++++ .../layout/app_bar_progress_indicator.dart | 20 +-- .../message_list/message_filter_chiplet.dart | 36 ++++ .../lib/pages/message_list/message_list.dart | 68 ++++++-- .../lib/pages/message_view/message_view.dart | 14 ++ flutter/lib/state/app_bar_state.dart | 25 +++ flutter/lib/state/fb_message.dart | 4 +- flutter/lib/state/globals.dart | 1 - flutter/lib/utils/navi.dart | 9 + 10 files changed, 354 insertions(+), 64 deletions(-) create mode 100644 flutter/lib/components/layout/app_bar_filter_dialog.dart create mode 100644 flutter/lib/pages/message_list/message_filter_chiplet.dart diff --git a/flutter/lib/components/layout/app_bar.dart b/flutter/lib/components/layout/app_bar.dart index c0e63ed..d9755b0 100644 --- a/flutter/lib/components/layout/app_bar.dart +++ b/flutter/lib/components/layout/app_bar.dart @@ -1,14 +1,18 @@ import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:provider/provider.dart'; +import 'package:simplecloudnotifier/components/layout/app_bar_filter_dialog.dart'; import 'package:simplecloudnotifier/components/layout/app_bar_progress_indicator.dart'; import 'package:simplecloudnotifier/pages/debug/debug_main.dart'; import 'package:simplecloudnotifier/settings/app_settings.dart'; +import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/app_theme.dart'; import 'package:simplecloudnotifier/utils/navi.dart'; +import 'package:simplecloudnotifier/utils/toaster.dart'; -class SCNAppBar extends StatelessWidget implements PreferredSizeWidget { - const SCNAppBar({ +class SCNAppBar extends StatefulWidget implements PreferredSizeWidget { + SCNAppBar({ Key? key, required this.title, required this.showThemeSwitch, @@ -23,6 +27,22 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget { final bool showShare; final void Function()? onShare; + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); + + @override + State createState() => _SCNAppBarState(); +} + +class _SCNAppBarState extends State { + final TextEditingController _ctrlSearchField = TextEditingController(); + + @override + void dispose() { + _ctrlSearchField.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final cfg = Provider.of(context); @@ -39,7 +59,7 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget { )); } - if (showThemeSwitch) { + if (widget.showThemeSwitch) { actions.add(Consumer( builder: (context, appTheme, child) => IconButton( icon: Icon(appTheme.darkMode ? FontAwesomeIcons.solidSun : FontAwesomeIcons.solidMoon), @@ -48,54 +68,117 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget { ), )); } else { - actions.add(Visibility( - visible: false, - maintainSize: true, - maintainAnimation: true, - maintainState: true, - child: IconButton( - icon: const Icon(FontAwesomeIcons.square), - onPressed: () {/*TODO*/}, - ), - )); + actions.add(_buildSpacer()); } - if (showSearch) { + if (widget.showSearch) { + actions.add(IconButton( + icon: const Icon(FontAwesomeIcons.solidFilter), + tooltip: 'Filter', + onPressed: () => _showFilterDialog(context), + )); actions.add(IconButton( icon: const Icon(FontAwesomeIcons.solidMagnifyingGlass), tooltip: 'Search', - onPressed: () {/*TODO*/}, + onPressed: () => AppBarState().setShowSearchField(true), )); - } else if (showShare) { + } else if (widget.showShare) { + actions.add(_buildSpacer()); actions.add(IconButton( icon: const Icon(FontAwesomeIcons.solidShareNodes), tooltip: 'Share', - onPressed: onShare ?? () {}, + onPressed: widget.onShare ?? () {}, )); } else { - actions.add(Visibility( - visible: false, - maintainSize: true, - maintainAnimation: true, - maintainState: true, - child: IconButton( - icon: const Icon(FontAwesomeIcons.square), - onPressed: () {/*TODO*/}, - ), - )); + actions.add(_buildSpacer()); } - return AppBar( - title: Text(title ?? 'Simple Cloud Notifier 2.0'), - actions: actions, - backgroundColor: Theme.of(context).secondaryHeaderColor, - bottom: PreferredSize( - preferredSize: Size(double.infinity, 1.0), - child: AppBarProgressIndicator(), + return Consumer(builder: (context, value, child) { + if (value.showSearchField) { + return AppBar( + leading: IconButton( + icon: const Icon(FontAwesomeIcons.solidArrowLeft), + onPressed: () { + value.setShowSearchField(false); + }, + ), + title: _buildSearchTextField(context), + actions: [ + IconButton( + icon: const Icon(FontAwesomeIcons.solidMagnifyingGlass), + onPressed: () { + value.setShowSearchField(false); + AppBarState().notifySearchListeners(_ctrlSearchField.text); + _ctrlSearchField.clear(); + }, + ), + ], + backgroundColor: Theme.of(context).secondaryHeaderColor, + bottom: PreferredSize( + preferredSize: Size(double.infinity, 1.0), + child: AppBarProgressIndicator(show: value.loadingIndeterminate), + ), + ); + } else { + return AppBar( + title: Text(widget.title ?? 'SCN'), + actions: actions, + backgroundColor: Theme.of(context).secondaryHeaderColor, + bottom: PreferredSize( + preferredSize: Size(double.infinity, 1.0), + child: AppBarProgressIndicator(show: value.loadingIndeterminate), + ), + ); + } + }); + } + + Visibility _buildSpacer() { + return Visibility( + visible: false, + maintainSize: true, + maintainAnimation: true, + maintainState: true, + child: IconButton( + icon: const Icon(FontAwesomeIcons.square), + onPressed: () {/* NO-OP */}, ), ); } - @override - Size get preferredSize => const Size.fromHeight(kToolbarHeight); + Widget _buildSearchTextField(BuildContext context) { + return TextField( + controller: _ctrlSearchField, + autofocus: true, + style: TextStyle(fontSize: 20), + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: 'Search', + ), + onSubmitted: (value) { + AppBarState().setShowSearchField(false); + AppBarState().notifySearchListeners(_ctrlSearchField.text); + _ctrlSearchField.clear(); + }, + ); + } + + void _showFilterDialog(BuildContext context) { + double vpWidth = MediaQuery.sizeOf(context).width; + + showDialog( + context: context, + barrierDismissible: true, + barrierColor: Colors.transparent, + builder: (BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(0)), + alignment: Alignment.topCenter, + insetPadding: EdgeInsets.fromLTRB(0, this.widget.preferredSize.height, 0, 0), + backgroundColor: Colors.transparent, + child: AppBarFilterDialog(), + ); + }, + ); + } } diff --git a/flutter/lib/components/layout/app_bar_filter_dialog.dart b/flutter/lib/components/layout/app_bar_filter_dialog.dart new file mode 100644 index 0000000..a201801 --- /dev/null +++ b/flutter/lib/components/layout/app_bar_filter_dialog.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:simplecloudnotifier/utils/navi.dart'; + +class AppBarFilterDialog extends StatefulWidget { + @override + _AppBarFilterDialogState createState() => _AppBarFilterDialogState(); +} + +class _AppBarFilterDialogState extends State { + double _height = 0; + + double _targetHeight = 4 + (48 * 6) + (16 * 5) + 4; + + @override + void initState() { + super.initState(); + Future.delayed(Duration.zero, () { + setState(() { + _height = _targetHeight; + }); + }); + } + + @override + Widget build(BuildContext context) { + double vpWidth = MediaQuery.sizeOf(context).width; + + return Container( + margin: const EdgeInsets.all(0), + width: vpWidth, + color: Colors.transparent, + child: Column( + children: [ + Container( + color: Theme.of(context).secondaryHeaderColor, + child: AnimatedContainer( + duration: Duration(milliseconds: 350), + curve: Curves.easeInCubic, + height: _height, + child: ClipRect( + child: OverflowBox( + alignment: Alignment.topCenter, + maxWidth: vpWidth, + minWidth: vpWidth, + minHeight: 0, + maxHeight: _targetHeight, + child: Column( + children: [ + SizedBox(height: 4), + _buildFilterItem(context, FontAwesomeIcons.magnifyingGlass, 'Search'), + Divider(), + _buildFilterItem(context, FontAwesomeIcons.snake, 'Channel'), + Divider(), + _buildFilterItem(context, FontAwesomeIcons.signature, 'Sender'), + Divider(), + _buildFilterItem(context, FontAwesomeIcons.timer, 'Time'), + Divider(), + _buildFilterItem(context, FontAwesomeIcons.bolt, 'Priority'), + Divider(), + _buildFilterItem(context, FontAwesomeIcons.gearCode, 'Key'), + SizedBox(height: 4), + ], + ), + ), + ), + ), + ), + Expanded(child: GestureDetector(child: Container(width: vpWidth, color: Color(0x88000000)), onTap: () => Navi.popDialog(context))), + ], + ), + ); + } + + Widget _buildFilterItem(BuildContext context, IconData icon, String label) { + return ListTile( + visualDensity: VisualDensity.compact, + title: Text(label), + leading: Icon(icon), + onTap: () { + Navi.popDialog(context); + //TOOD show more... + }, + ); + } +} diff --git a/flutter/lib/components/layout/app_bar_progress_indicator.dart b/flutter/lib/components/layout/app_bar_progress_indicator.dart index c227ea7..9eb3e89 100644 --- a/flutter/lib/components/layout/app_bar_progress_indicator.dart +++ b/flutter/lib/components/layout/app_bar_progress_indicator.dart @@ -1,21 +1,19 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:simplecloudnotifier/state/app_bar_state.dart'; class AppBarProgressIndicator extends StatelessWidget implements PreferredSizeWidget { + AppBarProgressIndicator({required this.show}); + + final bool show; + @override Size get preferredSize => Size(double.infinity, 1.0); @override Widget build(BuildContext context) { - return Consumer( - builder: (context, value, child) { - if (value.loadingIndeterminate) { - return LinearProgressIndicator(value: null); - } else { - return SizedBox.square(dimension: 4); // 4 height is the same as the LinearProgressIndicator - } - }, - ); + if (show) { + return LinearProgressIndicator(value: null); + } else { + return SizedBox.square(dimension: 4); // 4 height is the same as the LinearProgressIndicator + } } } diff --git a/flutter/lib/pages/message_list/message_filter_chiplet.dart b/flutter/lib/pages/message_list/message_filter_chiplet.dart new file mode 100644 index 0000000..86f99bd --- /dev/null +++ b/flutter/lib/pages/message_list/message_filter_chiplet.dart @@ -0,0 +1,36 @@ +import 'package:flutter/src/widgets/icon_data.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; + +enum MessageFilterChipletType { + search, + channel, + sender, + timeRange, + priority, + sendkey, +} + +class MessageFilterChiplet { + final String label; + final String value; + final MessageFilterChipletType type; + + MessageFilterChiplet({required this.label, required this.value, required this.type}); + + IconData? icon() { + switch (type) { + case MessageFilterChipletType.search: + return FontAwesomeIcons.magnifyingGlass; + case MessageFilterChipletType.channel: + return FontAwesomeIcons.snake; + case MessageFilterChipletType.sender: + return FontAwesomeIcons.signature; + case MessageFilterChipletType.timeRange: + return FontAwesomeIcons.timer; + case MessageFilterChipletType.priority: + return FontAwesomeIcons.bolt; + case MessageFilterChipletType.sendkey: + return FontAwesomeIcons.gearCode; + } + } +} diff --git a/flutter/lib/pages/message_list/message_list.dart b/flutter/lib/pages/message_list/message_list.dart index fe094dc..b9cc8b7 100644 --- a/flutter/lib/pages/message_list/message_list.dart +++ b/flutter/lib/pages/message_list/message_list.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import 'package:simplecloudnotifier/api/api_client.dart'; import 'package:simplecloudnotifier/models/channel.dart'; import 'package:simplecloudnotifier/models/scn_message.dart'; +import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart'; import 'package:simplecloudnotifier/pages/message_view/message_view.dart'; import 'package:simplecloudnotifier/settings/app_settings.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart'; @@ -34,10 +35,14 @@ class _MessageListPageState extends State with RouteAware { bool _isInitialized = false; + List _filterChiplets = []; + @override void initState() { super.initState(); + AppBarState().subscribeSearchListener(_onAppBarSearch); + _pagingController.addPageRequestListener(_fetchPage); if (widget.isVisiblePage && !_isInitialized) _realInitState(); @@ -94,6 +99,7 @@ class _MessageListPageState extends State with RouteAware { @override void dispose() { ApplicationLog.debug('MessageListPage::dispose'); + AppBarState().unsubscribeSearchListener(_onAppBarSearch); Navi.modalRouteObserver.unsubscribe(this); _pagingController.dispose(); _lifecyleListener.dispose(); @@ -222,22 +228,50 @@ class _MessageListPageState extends State with RouteAware { Widget build(BuildContext context) { return Padding( padding: EdgeInsets.fromLTRB(8, 4, 8, 4), - child: RefreshIndicator( - onRefresh: () => Future.sync( - () => _pagingController.refresh(), - ), - child: PagedListView( - pagingController: _pagingController, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) => MessageListItem( - message: item, - allChannels: _channels ?? {}, - onPressed: () { - Navi.push(context, () => MessageViewPage(message: item)); - }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_filterChiplets.isNotEmpty) + Wrap( + alignment: WrapAlignment.start, + spacing: 5.0, + children: [ + for (var chiplet in _filterChiplets) _buildFilterChip(context, chiplet), + ], + ), + Expanded( + child: RefreshIndicator( + onRefresh: () => Future.sync( + () => _pagingController.refresh(), + ), + child: PagedListView( + pagingController: _pagingController, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => MessageListItem( + message: item, + allChannels: _channels ?? {}, + onPressed: () { + Navi.push(context, () => MessageViewPage(message: item)); + }, + ), + ), + ), ), ), - ), + ], + ), + ); + } + + Widget _buildFilterChip(BuildContext context, MessageFilterChiplet chiplet) { + return Padding( + padding: const EdgeInsets.fromLTRB(0, 2, 0, 2), + child: InputChip( + avatar: Icon(chiplet.icon()), + label: Text(chiplet.label), + onDeleted: () => setState(() => _filterChiplets.remove(chiplet)), + onPressed: () {/* TODO idk what to do here ? */}, + visualDensity: VisualDensity(horizontal: -4, vertical: -4), ), ); } @@ -269,4 +303,10 @@ class _MessageListPageState extends State with RouteAware { await cache.delete(val.messageID); } } + + void _onAppBarSearch(String str) { + setState(() { + _filterChiplets = _filterChiplets.where((element) => false).toList() + [MessageFilterChiplet(label: str, value: str, type: MessageFilterChipletType.search)]; + }); + } } diff --git a/flutter/lib/pages/message_view/message_view.dart b/flutter/lib/pages/message_view/message_view.dart index 29c51c9..d49ccc5 100644 --- a/flutter/lib/pages/message_view/message_view.dart +++ b/flutter/lib/pages/message_view/message_view.dart @@ -137,6 +137,7 @@ class _MessageViewPageState extends State { _buildMetaCard(context, FontAwesomeIcons.solidSnake, 'Channel', [message.channelID, channel?.displayName ?? message.channelInternalName], () => {/*TODO*/}), _buildMetaCard(context, FontAwesomeIcons.solidTimer, 'Timestamp', [message.timestamp], null), _buildMetaCard(context, FontAwesomeIcons.solidUser, 'User', [if (user != null) user.userID, if (user?.username != null) user!.username!], () => {/*TODO*/}), //TODO + _buildMetaCard(context, FontAwesomeIcons.solidBolt, 'Priority', [_prettyPrintPriority(message.priority)], () => {/*TODO*/}), //TODO if (message.senderUserID == userAccUserID) UI.button(text: "Delete Message", onPressed: () {/*TODO*/}, color: Colors.red[900]), ], ), @@ -252,4 +253,17 @@ class _MessageViewPageState extends State { String _preformatTitle(SCNMessage message) { return message.title.replaceAll('\n', '').replaceAll('\r', '').replaceAll('\t', ' '); } + + String _prettyPrintPriority(int priority) { + switch (priority) { + case 0: + return 'Low (0)'; + case 1: + return 'Normal (1)'; + case 2: + return 'High (2)'; + default: + return 'Unknown ($priority)'; + } + } } diff --git a/flutter/lib/state/app_bar_state.dart b/flutter/lib/state/app_bar_state.dart index b62384a..0aeac53 100644 --- a/flutter/lib/state/app_bar_state.dart +++ b/flutter/lib/state/app_bar_state.dart @@ -9,12 +9,37 @@ class AppBarState extends ChangeNotifier { AppBarState._internal() {} + List _searchListeners = []; + bool _loadingIndeterminate = false; bool get loadingIndeterminate => _loadingIndeterminate; + bool _showSearchField = false; + bool get showSearchField => _showSearchField; + void setLoadingIndeterminate(bool v) { if (_loadingIndeterminate == v) return; _loadingIndeterminate = v; notifyListeners(); } + + void setShowSearchField(bool v) { + if (_showSearchField == v) return; + _showSearchField = v; + notifyListeners(); + } + + void subscribeSearchListener(void Function(String) listener) { + _searchListeners.add(listener); + } + + void unsubscribeSearchListener(void Function(String) listener) { + _searchListeners.remove(listener); + } + + void notifySearchListeners(String query) { + for (var listener in _searchListeners) { + listener(query); + } + } } diff --git a/flutter/lib/state/fb_message.dart b/flutter/lib/state/fb_message.dart index bd9782b..ea913c1 100644 --- a/flutter/lib/state/fb_message.dart +++ b/flutter/lib/state/fb_message.dart @@ -158,11 +158,11 @@ class FBMessage extends HiveObject implements FieldDebuggable { this.notificationAndroidCount = rmsg.notification?.android?.count, this.notificationAndroidImageUrl = rmsg.notification?.android?.imageUrl, this.notificationAndroidLink = rmsg.notification?.android?.link, - this.notificationAndroidPriority = rmsg.notification?.android?.priority?.toString(), + this.notificationAndroidPriority = rmsg.notification?.android?.priority.toString(), this.notificationAndroidSmallIcon = rmsg.notification?.android?.smallIcon, this.notificationAndroidSound = rmsg.notification?.android?.sound, this.notificationAndroidTicker = rmsg.notification?.android?.ticker, - this.notificationAndroidVisibility = rmsg.notification?.android?.visibility?.toString(), + this.notificationAndroidVisibility = rmsg.notification?.android?.visibility.toString(), this.notificationAndroidTag = rmsg.notification?.android?.tag, this.notificationAppleBadge = rmsg.notification?.apple?.badge, this.notificationAppleSound = rmsg.notification?.apple?.sound?.toString(), diff --git a/flutter/lib/state/globals.dart b/flutter/lib/state/globals.dart index d54fa56..004fd2a 100644 --- a/flutter/lib/state/globals.dart +++ b/flutter/lib/state/globals.dart @@ -1,4 +1,3 @@ -import 'dart:ffi'; import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; diff --git a/flutter/lib/utils/navi.dart b/flutter/lib/utils/navi.dart index 61d9d93..c3e6510 100644 --- a/flutter/lib/utils/navi.dart +++ b/flutter/lib/utils/navi.dart @@ -8,15 +8,21 @@ class Navi { static void push(BuildContext context, T Function() builder) { Provider.of(context, listen: false).setLoadingIndeterminate(false); + Provider.of(context, listen: false).setShowSearchField(false); Navigator.push(context, MaterialPageRoute(builder: (context) => builder())); } static void popToRoot(BuildContext context) { Provider.of(context, listen: false).setLoadingIndeterminate(false); + Provider.of(context, listen: false).setShowSearchField(false); Navigator.popUntil(context, (route) => route.isFirst); } + + static void popDialog(BuildContext dialogContext) { + Navigator.pop(dialogContext); + } } class SCNRouteObserver extends RouteObserver> { @@ -25,6 +31,7 @@ class SCNRouteObserver extends RouteObserver> { super.didPush(route, previousRoute); if (route is PageRoute) { AppBarState().setLoadingIndeterminate(false); + AppBarState().setShowSearchField(false); print('[SCNRouteObserver] .didPush()'); } @@ -35,6 +42,7 @@ class SCNRouteObserver extends RouteObserver> { super.didReplace(newRoute: newRoute, oldRoute: oldRoute); if (newRoute is PageRoute) { AppBarState().setLoadingIndeterminate(false); + AppBarState().setShowSearchField(false); print('[SCNRouteObserver] .didReplace()'); } @@ -45,6 +53,7 @@ class SCNRouteObserver extends RouteObserver> { super.didPop(route, previousRoute); if (previousRoute is PageRoute && route is PageRoute) { AppBarState().setLoadingIndeterminate(false); + AppBarState().setShowSearchField(false); print('[SCNRouteObserver] .didPop()'); }