Working on message search+filter

This commit is contained in:
Mike Schwörer 2024-06-16 00:46:46 +02:00
parent e9ea573e33
commit 0bbe5fc7fa
Signed by: Mikescher
GPG Key ID: D3C7172E0A70F8CF
10 changed files with 354 additions and 64 deletions

View File

@ -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<SCNAppBar> createState() => _SCNAppBarState();
}
class _SCNAppBarState extends State<SCNAppBar> {
final TextEditingController _ctrlSearchField = TextEditingController();
@override
void dispose() {
_ctrlSearchField.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final cfg = Provider.of<AppSettings>(context);
@ -39,7 +59,7 @@ class SCNAppBar extends StatelessWidget implements PreferredSizeWidget {
));
}
if (showThemeSwitch) {
if (widget.showThemeSwitch) {
actions.add(Consumer<AppTheme>(
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<AppBarState>(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<void>(
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(),
);
},
);
}
}

View File

@ -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<AppBarFilterDialog> {
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...
},
);
}
}

View File

@ -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<AppBarState>(
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
}
}
}

View File

@ -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;
}
}
}

View File

@ -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<MessageListPage> with RouteAware {
bool _isInitialized = false;
List<MessageFilterChiplet> _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<MessageListPage> 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<MessageListPage> with RouteAware {
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.fromLTRB(8, 4, 8, 4),
child: RefreshIndicator(
onRefresh: () => Future.sync(
() => _pagingController.refresh(),
),
child: PagedListView<String, SCNMessage>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<SCNMessage>(
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<String, SCNMessage>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<SCNMessage>(
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<MessageListPage> 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)];
});
}
}

View File

@ -137,6 +137,7 @@ class _MessageViewPageState extends State<MessageViewPage> {
_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<MessageViewPage> {
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)';
}
}
}

View File

@ -9,12 +9,37 @@ class AppBarState extends ChangeNotifier {
AppBarState._internal() {}
List<void Function(String)> _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);
}
}
}

View File

@ -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(),

View File

@ -1,4 +1,3 @@
import 'dart:ffi';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';

View File

@ -8,15 +8,21 @@ class Navi {
static void push<T extends Widget>(BuildContext context, T Function() builder) {
Provider.of<AppBarState>(context, listen: false).setLoadingIndeterminate(false);
Provider.of<AppBarState>(context, listen: false).setShowSearchField(false);
Navigator.push(context, MaterialPageRoute<T>(builder: (context) => builder()));
}
static void popToRoot(BuildContext context) {
Provider.of<AppBarState>(context, listen: false).setLoadingIndeterminate(false);
Provider.of<AppBarState>(context, listen: false).setShowSearchField(false);
Navigator.popUntil(context, (route) => route.isFirst);
}
static void popDialog(BuildContext dialogContext) {
Navigator.pop(dialogContext);
}
}
class SCNRouteObserver extends RouteObserver<PageRoute<dynamic>> {
@ -25,6 +31,7 @@ class SCNRouteObserver extends RouteObserver<PageRoute<dynamic>> {
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<PageRoute<dynamic>> {
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<PageRoute<dynamic>> {
super.didPop(route, previousRoute);
if (previousRoute is PageRoute && route is PageRoute) {
AppBarState().setLoadingIndeterminate(false);
AppBarState().setShowSearchField(false);
print('[SCNRouteObserver] .didPop()');
}