diff --git a/flutter/lib/api/api_client.dart b/flutter/lib/api/api_client.dart index 8066b79..7dd11b0 100644 --- a/flutter/lib/api/api_client.dart +++ b/flutter/lib/api/api_client.dart @@ -27,6 +27,26 @@ enum ChannelSelector { final String apiKey; } +class MessageFilter { + List? channelIDs; + String? searchFilter; + List? senderNames; + List? usedKeys; + List? priority; + DateTime? timeBefore; + DateTime? timeAfter; + + MessageFilter({ + this.channelIDs, + this.searchFilter, + this.senderNames, + this.usedKeys, + this.priority, + this.timeBefore, + this.timeAfter, + }); +} + class APIClient { static const String _base = 'https://simplecloudnotifier.de/api/v2'; @@ -226,7 +246,7 @@ class APIClient { ); } - static Future<(String, List)> getMessageList(TokenSource auth, String pageToken, {int? pageSize, List? channelIDs}) async { + static Future<(String, List)> getMessageList(TokenSource auth, String pageToken, {int? pageSize, MessageFilter? filter}) async { return await _request( name: 'getMessageList', method: 'GET', @@ -234,7 +254,12 @@ class APIClient { query: { 'next_page_token': pageToken, if (pageSize != null) 'page_size': pageSize.toString(), - if (channelIDs != null) 'channel_id': channelIDs.join(","), + if (filter?.channelIDs != null) 'channel_id': filter!.channelIDs!.join(","), + if (filter?.senderNames != null) 'sender': filter!.senderNames!.join(","), + if (filter?.timeBefore != null) 'before': filter!.timeBefore!.toIso8601String(), + if (filter?.timeAfter != null) 'after': filter!.timeAfter!.toIso8601String(), + if (filter?.priority != null) 'priority': filter!.priority!.map((p) => p.toString()).join(","), + if (filter?.usedKeys != null) 'used_key': filter!.usedKeys!.join(","), }, fn: (json) => SCNMessage.fromPaginatedJsonArray(json, 'messages', 'next_page_token'), authToken: auth.getToken(), @@ -339,4 +364,8 @@ class APIClient { authToken: token, ); } + + static Future> getSenderNameList(AppAuth userAcc) { + return Future.value(['TODO']); //TODO + } } diff --git a/flutter/lib/components/layout/app_bar.dart b/flutter/lib/components/layout/app_bar.dart index b171c7a..81b4fa2 100644 --- a/flutter/lib/components/layout/app_bar.dart +++ b/flutter/lib/components/layout/app_bar.dart @@ -4,6 +4,7 @@ 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/pages/message_list/message_filter_chiplet.dart'; import 'package:simplecloudnotifier/settings/app_settings.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/app_events.dart'; @@ -108,7 +109,8 @@ class _SCNAppBarState extends State { icon: const Icon(FontAwesomeIcons.solidMagnifyingGlass), onPressed: () { value.setShowSearchField(false); - AppEvents().notifySearchListeners(_ctrlSearchField.text); + final chiplet = MessageFilterChiplet(label: _ctrlSearchField.text, value: _ctrlSearchField.text, type: MessageFilterChipletType.search); + AppEvents().notifyFilterListeners([MessageFilterChipletType.search], [chiplet]); _ctrlSearchField.clear(); }, ), @@ -157,7 +159,8 @@ class _SCNAppBarState extends State { ), onSubmitted: (value) { AppBarState().setShowSearchField(false); - AppEvents().notifySearchListeners(_ctrlSearchField.text); + final chiplet = MessageFilterChiplet(label: _ctrlSearchField.text, value: _ctrlSearchField.text, type: MessageFilterChipletType.search); + AppEvents().notifyFilterListeners([MessageFilterChipletType.search], [chiplet]); _ctrlSearchField.clear(); }, ); diff --git a/flutter/lib/components/layout/app_bar_filter_dialog.dart b/flutter/lib/components/layout/app_bar_filter_dialog.dart index a201801..a2f8ee5 100644 --- a/flutter/lib/components/layout/app_bar_filter_dialog.dart +++ b/flutter/lib/components/layout/app_bar_filter_dialog.dart @@ -1,5 +1,11 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:simplecloudnotifier/components/modals/filter_modal_channel.dart'; +import 'package:simplecloudnotifier/components/modals/filter_modal_keytoken.dart'; +import 'package:simplecloudnotifier/components/modals/filter_modal_priority.dart'; +import 'package:simplecloudnotifier/components/modals/filter_modal_sendername.dart'; +import 'package:simplecloudnotifier/components/modals/filter_modal_time.dart'; +import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/utils/navi.dart'; class AppBarFilterDialog extends StatefulWidget { @@ -48,17 +54,17 @@ class _AppBarFilterDialogState extends State { child: Column( children: [ SizedBox(height: 4), - _buildFilterItem(context, FontAwesomeIcons.magnifyingGlass, 'Search'), + _buildFilterItem(context, FontAwesomeIcons.magnifyingGlass, 'Search', _showSearch), Divider(), - _buildFilterItem(context, FontAwesomeIcons.snake, 'Channel'), + _buildFilterItem(context, FontAwesomeIcons.snake, 'Channel', _showChannelModal), Divider(), - _buildFilterItem(context, FontAwesomeIcons.signature, 'Sender'), + _buildFilterItem(context, FontAwesomeIcons.signature, 'Sender', _showSenderModal), Divider(), - _buildFilterItem(context, FontAwesomeIcons.timer, 'Time'), + _buildFilterItem(context, FontAwesomeIcons.timer, 'Time', _showTimeModal), Divider(), - _buildFilterItem(context, FontAwesomeIcons.bolt, 'Priority'), + _buildFilterItem(context, FontAwesomeIcons.bolt, 'Priority', _showPriorityModal), Divider(), - _buildFilterItem(context, FontAwesomeIcons.gearCode, 'Key'), + _buildFilterItem(context, FontAwesomeIcons.gearCode, 'Key', _showKeytokenModal), SizedBox(height: 4), ], ), @@ -72,15 +78,39 @@ class _AppBarFilterDialogState extends State { ); } - Widget _buildFilterItem(BuildContext context, IconData icon, String label) { + Widget _buildFilterItem(BuildContext context, IconData icon, String label, void Function(BuildContext context) action) { return ListTile( visualDensity: VisualDensity.compact, title: Text(label), leading: Icon(icon), onTap: () { Navi.popDialog(context); - //TOOD show more... + action(context); }, ); } + + void _showSearch(BuildContext context) { + AppBarState().setShowSearchField(true); + } + + void _showPriorityModal(BuildContext context) { + showDialog(context: context, builder: (BuildContext context) => FilterModalPriority()); + } + + void _showChannelModal(BuildContext context) { + showDialog(context: context, builder: (BuildContext context) => FilterModalChannel()); + } + + void _showSenderModal(BuildContext context) { + showDialog(context: context, builder: (BuildContext context) => FilterModalSendername()); + } + + void _showKeytokenModal(BuildContext context) { + showDialog(context: context, builder: (BuildContext context) => FilterModalKeytoken()); + } + + void _showTimeModal(BuildContext context) { + showDialog(context: context, builder: (BuildContext context) => FilterModalTime()); + } } diff --git a/flutter/lib/components/modals/filter_modal_channel.dart b/flutter/lib/components/modals/filter_modal_channel.dart new file mode 100644 index 0000000..8137fd2 --- /dev/null +++ b/flutter/lib/components/modals/filter_modal_channel.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:simplecloudnotifier/api/api_client.dart'; +import 'package:simplecloudnotifier/models/channel.dart'; +import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart'; +import 'package:simplecloudnotifier/state/app_auth.dart'; +import 'package:simplecloudnotifier/state/app_events.dart'; +import 'package:simplecloudnotifier/types/immediate_future.dart'; + +class FilterModalChannel extends StatefulWidget { + @override + _FilterModalChannelState createState() => _FilterModalChannelState(); +} + +class _FilterModalChannelState extends State { + Set _selectedEntries = {}; + + late ImmediateFuture>? _futureChannels; + + @override + void initState() { + super.initState(); + + _futureChannels = null; + _futureChannels = ImmediateFuture.ofFuture(() async { + final userAcc = Provider.of(context, listen: false); + if (!userAcc.isAuth()) throw new Exception('not logged in'); + + final channels = await APIClient.getChannelList(userAcc, ChannelSelector.all); + + return channels.where((p) => p.subscription?.confirmed ?? false).map((e) => e.channel).toList(); // return only subscribed channels + }()); + } + + void toggleEntry(String channelID) { + setState(() { + if (_selectedEntries.contains(channelID)) { + _selectedEntries.remove(channelID); + } else { + _selectedEntries.add(channelID); + } + }); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Channels'), + content: Container( + width: 9000, + height: 9000, + child: () { + if (_futureChannels == null) { + return Center(child: CircularProgressIndicator()); + } + + return FutureBuilder( + future: _futureChannels!.future, + builder: ((context, snapshot) { + if (_futureChannels?.value != null) { + return _buildList(context, _futureChannels!.value!); + } else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) { + return Text('Error: ${snapshot.error}'); //TODO better error display + } else if (snapshot.connectionState == ConnectionState.done) { + return _buildList(context, snapshot.data!); + } else { + return Center(child: CircularProgressIndicator()); + } + }), + ); + }(), + ), + actions: [ + TextButton( + style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge), + child: const Text('Apply'), + onPressed: () { + onOkay(); + }, + ), + ], + ); + } + + void onOkay() { + Navigator.of(context).pop(); + + final chiplets = _selectedEntries + .map((e) => MessageFilterChiplet( + label: _futureChannels?.get()?.map((e) => e as Channel?).firstWhere((p) => p?.channelID == e, orElse: () => null)?.displayName ?? '???', + value: e, + type: MessageFilterChipletType.channel, + )) + .toList(); + + AppEvents().notifyFilterListeners([MessageFilterChipletType.channel], chiplets); + } + + Widget _buildList(BuildContext context, List list) { + return ListView.builder( + shrinkWrap: true, + itemBuilder: (builder, index) { + final channel = list[index]; + return ListTile( + title: Text(channel.displayName), + leading: Icon(_selectedEntries.contains(channel.channelID) ? Icons.check_box : Icons.check_box_outline_blank, color: Theme.of(context).primaryColor), + onTap: () => toggleEntry(channel.channelID), + visualDensity: VisualDensity(vertical: -4), + ); + }, + itemCount: list.length, + ); + } +} diff --git a/flutter/lib/components/modals/filter_modal_keytoken.dart b/flutter/lib/components/modals/filter_modal_keytoken.dart new file mode 100644 index 0000000..b2af4d4 --- /dev/null +++ b/flutter/lib/components/modals/filter_modal_keytoken.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:simplecloudnotifier/api/api_client.dart'; +import 'package:simplecloudnotifier/models/keytoken.dart'; +import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart'; +import 'package:simplecloudnotifier/state/app_auth.dart'; +import 'package:simplecloudnotifier/state/app_events.dart'; +import 'package:simplecloudnotifier/types/immediate_future.dart'; + +class FilterModalKeytoken extends StatefulWidget { + @override + _FilterModalKeytokenState createState() => _FilterModalKeytokenState(); +} + +class _FilterModalKeytokenState extends State { + Set _selectedEntries = {}; + + late ImmediateFuture>? _futureKeyTokens; + + @override + void initState() { + super.initState(); + + _futureKeyTokens = null; + _futureKeyTokens = ImmediateFuture.ofFuture(() async { + final userAcc = Provider.of(context, listen: false); + if (!userAcc.isAuth()) throw new Exception('not logged in'); + + final toks = await APIClient.getKeyTokenList(userAcc); + + return toks; + }()); + } + + void toggleEntry(String senderID) { + setState(() { + if (_selectedEntries.contains(senderID)) { + _selectedEntries.remove(senderID); + } else { + _selectedEntries.add(senderID); + } + }); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Senders'), + content: Container( + width: 9000, + height: 9000, + child: () { + if (_futureKeyTokens == null) { + return Center(child: CircularProgressIndicator()); + } + + return FutureBuilder( + future: _futureKeyTokens!.future, + builder: ((context, snapshot) { + if (_futureKeyTokens?.value != null) { + return _buildList(context, _futureKeyTokens!.value!); + } else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) { + return Text('Error: ${snapshot.error}'); //TODO better error display + } else if (snapshot.connectionState == ConnectionState.done) { + return _buildList(context, snapshot.data!); + } else { + return Center(child: CircularProgressIndicator()); + } + }), + ); + }(), + ), + actions: [ + TextButton( + style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge), + child: const Text('Apply'), + onPressed: () { + onOkay(); + }, + ), + ], + ); + } + + void onOkay() { + Navigator.of(context).pop(); + + final chiplets = _selectedEntries + .map((e) => MessageFilterChiplet( + label: _futureKeyTokens?.get()?.map((e) => e as KeyToken?).firstWhere((p) => p?.keytokenID == e, orElse: () => null)?.name ?? '???', + value: e, + type: MessageFilterChipletType.sender, + )) + .toList(); + + AppEvents().notifyFilterListeners([MessageFilterChipletType.sender], chiplets); + } + + Widget _buildList(BuildContext context, List list) { + return ListView.builder( + shrinkWrap: true, + itemBuilder: (builder, index) { + final sender = list[index]; + return ListTile( + title: Text(sender.name), + leading: Icon(_selectedEntries.contains(sender.keytokenID) ? Icons.check_box : Icons.check_box_outline_blank, color: Theme.of(context).primaryColor), + onTap: () => toggleEntry(sender.keytokenID), + visualDensity: VisualDensity(vertical: -4), + ); + }, + itemCount: list.length, + ); + } +} diff --git a/flutter/lib/components/modals/filter_modal_priority.dart b/flutter/lib/components/modals/filter_modal_priority.dart new file mode 100644 index 0000000..b6fe67c --- /dev/null +++ b/flutter/lib/components/modals/filter_modal_priority.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart'; +import 'package:simplecloudnotifier/state/app_events.dart'; + +class FilterModalPriority extends StatefulWidget { + @override + _FilterModalPriorityState createState() => _FilterModalPriorityState(); +} + +class _FilterModalPriorityState extends State { + Set _selectedEntries = {}; + + Map _texts = { + 0: ('Low (0)', 'Low'), + 1: ('Normal (1)', 'Normal'), + 2: ('High (2)', 'High'), + }; + + void toggleEntry(int entry) { + setState(() { + if (_selectedEntries.contains(entry)) { + _selectedEntries.remove(entry); + } else { + _selectedEntries.add(entry); + } + }); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Priority'), + content: Container( + width: 0, + height: 200, + child: ListView.builder( + shrinkWrap: true, + itemBuilder: (builder, index) { + return ListTile( + title: Text(_texts[index]?.$1 ?? '???'), + leading: Icon(_selectedEntries.contains(index) ? Icons.check_box : Icons.check_box_outline_blank, color: Theme.of(context).primaryColor), + onTap: () => toggleEntry(index), + ); + }, + itemCount: 3, + ), + ), + actions: [ + TextButton( + style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge), + child: const Text('Apply'), + onPressed: () { + onOkay(); + }, + ), + ], + ); + } + + void onOkay() { + Navigator.of(context).pop(); + + final chiplets = _selectedEntries.map((e) => MessageFilterChiplet(label: _texts[e]?.$2 ?? '???', value: e, type: MessageFilterChipletType.priority)).toList(); + + AppEvents().notifyFilterListeners([MessageFilterChipletType.priority], chiplets); + } +} diff --git a/flutter/lib/components/modals/filter_modal_sendername.dart b/flutter/lib/components/modals/filter_modal_sendername.dart new file mode 100644 index 0000000..2bc6c76 --- /dev/null +++ b/flutter/lib/components/modals/filter_modal_sendername.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:simplecloudnotifier/api/api_client.dart'; +import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart'; +import 'package:simplecloudnotifier/state/app_auth.dart'; +import 'package:simplecloudnotifier/state/app_events.dart'; +import 'package:simplecloudnotifier/types/immediate_future.dart'; + +class FilterModalSendername extends StatefulWidget { + @override + _FilterModalSendernameState createState() => _FilterModalSendernameState(); +} + +class _FilterModalSendernameState extends State { + Set _selectedEntries = {}; + + late ImmediateFuture>? _futureSenders; + + @override + void initState() { + super.initState(); + + _futureSenders = null; + _futureSenders = ImmediateFuture.ofFuture(() async { + final userAcc = Provider.of(context, listen: false); + if (!userAcc.isAuth()) throw new Exception('not logged in'); + + final senders = await APIClient.getSenderNameList(userAcc); + + return senders; + }()); + } + + void toggleEntry(String senderID) { + setState(() { + if (_selectedEntries.contains(senderID)) { + _selectedEntries.remove(senderID); + } else { + _selectedEntries.add(senderID); + } + }); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Senders'), + content: Container( + width: 9000, + height: 9000, + child: () { + if (_futureSenders == null) { + return Center(child: CircularProgressIndicator()); + } + + return FutureBuilder( + future: _futureSenders!.future, + builder: ((context, snapshot) { + if (_futureSenders?.value != null) { + return _buildList(context, _futureSenders!.value!); + } else if (snapshot.connectionState == ConnectionState.done && snapshot.hasError) { + return Text('Error: ${snapshot.error}'); //TODO better error display + } else if (snapshot.connectionState == ConnectionState.done) { + return _buildList(context, snapshot.data!); + } else { + return Center(child: CircularProgressIndicator()); + } + }), + ); + }(), + ), + actions: [ + TextButton( + style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge), + child: const Text('Apply'), + onPressed: () { + onOkay(); + }, + ), + ], + ); + } + + void onOkay() { + Navigator.of(context).pop(); + + final chiplets = _selectedEntries + .map((e) => MessageFilterChiplet( + label: e, + value: e, + type: MessageFilterChipletType.sender, + )) + .toList(); + + AppEvents().notifyFilterListeners([MessageFilterChipletType.sender], chiplets); + } + + Widget _buildList(BuildContext context, List list) { + return ListView.builder( + shrinkWrap: true, + itemBuilder: (builder, index) { + final sender = list[index]; + return ListTile( + title: Text(sender), + leading: Icon(_selectedEntries.contains(sender) ? Icons.check_box : Icons.check_box_outline_blank, color: Theme.of(context).primaryColor), + onTap: () => toggleEntry(sender), + visualDensity: VisualDensity(vertical: -4), + ); + }, + itemCount: list.length, + ); + } +} diff --git a/flutter/lib/components/modals/filter_modal_time.dart b/flutter/lib/components/modals/filter_modal_time.dart new file mode 100644 index 0000000..9486f39 --- /dev/null +++ b/flutter/lib/components/modals/filter_modal_time.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:simplecloudnotifier/api/api_client.dart'; +import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart'; +import 'package:simplecloudnotifier/state/app_auth.dart'; +import 'package:simplecloudnotifier/state/app_events.dart'; +import 'package:simplecloudnotifier/types/immediate_future.dart'; + +class FilterModalTime extends StatefulWidget { + @override + _FilterModalTimeState createState() => _FilterModalTimeState(); +} + +class _FilterModalTimeState extends State { + DateTime? _tsBefore = null; + DateTime? _tsAfter = null; + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Timerange'), + content: Container( + width: 9000, + height: 9000, + child: Placeholder(), + ), + actions: [ + TextButton( + style: TextButton.styleFrom(textStyle: Theme.of(context).textTheme.labelLarge), + child: const Text('Apply'), + onPressed: () { + onOkay(); + }, + ), + ], + ); + } + + void onOkay() { + Navigator.of(context).pop(); + + //TODO + } +} diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index 5b552d4..650f7fd 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -359,7 +359,7 @@ Future _receiveMessage(RemoteMessage message, bool foreground) async { SCNDataCache().addToMessageCache([msg]); if (foreground) AppEvents().notifyMessageReceivedListeners(msg); } catch (exc, trace) { - ApplicationLog.error('Failed to query+persist message' + exc.toString(), trace: trace); + ApplicationLog.error('Failed to query+persist message: ' + exc.toString(), trace: trace); return; } } diff --git a/flutter/lib/models/channel.dart b/flutter/lib/models/channel.dart index c912977..d04bf25 100644 --- a/flutter/lib/models/channel.dart +++ b/flutter/lib/models/channel.dart @@ -12,11 +12,11 @@ class Channel extends HiveObject implements FieldDebuggable { @HiveField(10) final String ownerUserID; @HiveField(11) - final String internalName; + final String internalName; // = InternalName, used for sending, normalized, cannot be changed @HiveField(12) - final String displayName; + final String displayName; // = DisplayName, used for display purposes, can be changed, initially equals InternalName @HiveField(13) - final String? descriptionName; + final String? descriptionName; // = DescriptionName, (optional), longer description text, initally nil @HiveField(14) final String? subscribeKey; @HiveField(15) diff --git a/flutter/lib/pages/channel_list/channel_list.dart b/flutter/lib/pages/channel_list/channel_list.dart index cf1e884..cdae5d5 100644 --- a/flutter/lib/pages/channel_list/channel_list.dart +++ b/flutter/lib/pages/channel_list/channel_list.dart @@ -75,7 +75,7 @@ class _ChannelRootPageState extends State with RouteAware { () async { _reloadEnqueued = false; AppBarState().setLoadingIndeterminate(true); - await Future.delayed(const Duration(milliseconds: 500)); // prevents flutter bug where the whole process crashes ?!? + await Future.delayed(const Duration(milliseconds: 500), () {}); // prevents flutter bug where the whole process crashes ?!? await _backgroundRefresh(); }(); } diff --git a/flutter/lib/pages/channel_list/channel_list_item.dart b/flutter/lib/pages/channel_list/channel_list_item.dart index 5f43148..eb7ae00 100644 --- a/flutter/lib/pages/channel_list/channel_list_item.dart +++ b/flutter/lib/pages/channel_list/channel_list_item.dart @@ -45,7 +45,7 @@ class _ChannelListItemState extends State { 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]); + final (_, channelMessages) = await APIClient.getMessageList(acc, '@start', pageSize: 1, filter: MessageFilter(channelIDs: [widget.channel.channelID])); setState(() { lastMessage = channelMessages.firstOrNull; }); diff --git a/flutter/lib/pages/channel_message_view/channel_message_view.dart b/flutter/lib/pages/channel_message_view/channel_message_view.dart index 42a9068..704b9eb 100644 --- a/flutter/lib/pages/channel_message_view/channel_message_view.dart +++ b/flutter/lib/pages/channel_message_view/channel_message_view.dart @@ -55,7 +55,7 @@ class _ChannelMessageViewPageState extends State { } try { - final (npt, newItems) = await APIClient.getMessageList(acc, thisPageToken, pageSize: cfg.messagePageSize, channelIDs: [this.widget.channel.channelID]); + final (npt, newItems) = await APIClient.getMessageList(acc, thisPageToken, pageSize: cfg.messagePageSize, filter: MessageFilter(channelIDs: [this.widget.channel.channelID])); SCNDataCache().addToMessageCache(newItems); // no await diff --git a/flutter/lib/pages/message_list/message_filter_chiplet.dart b/flutter/lib/pages/message_list/message_filter_chiplet.dart index 86f99bd..cfc8220 100644 --- a/flutter/lib/pages/message_list/message_filter_chiplet.dart +++ b/flutter/lib/pages/message_list/message_filter_chiplet.dart @@ -11,8 +11,8 @@ enum MessageFilterChipletType { } class MessageFilterChiplet { - final String label; - final String value; + final String label; // display value + final dynamic value; // search/api value final MessageFilterChipletType type; MessageFilterChiplet({required this.label, required this.value, required this.type}); diff --git a/flutter/lib/pages/message_list/message_list.dart b/flutter/lib/pages/message_list/message_list.dart index cc39226..eaae496 100644 --- a/flutter/lib/pages/message_list/message_list.dart +++ b/flutter/lib/pages/message_list/message_list.dart @@ -39,7 +39,7 @@ class _MessageListPageState extends State with RouteAware { void initState() { super.initState(); - AppEvents().subscribeSearchListener(_onAppBarSearch); + AppEvents().subscribeFilterListener(_onAddFilter); AppEvents().subscribeMessageReceivedListener(_onMessageReceivedViaNotification); _pagingController.addPageRequestListener(_fetchPage); @@ -92,7 +92,7 @@ class _MessageListPageState extends State with RouteAware { @override void dispose() { ApplicationLog.debug('MessageListPage::dispose'); - AppEvents().unsubscribeSearchListener(_onAppBarSearch); + AppEvents().unsubscribeFilterListener(_onAddFilter); AppEvents().unsubscribeMessageReceivedListener(_onMessageReceivedViaNotification); Navi.modalRouteObserver.unsubscribe(this); _pagingController.dispose(); @@ -139,7 +139,7 @@ class _MessageListPageState extends State with RouteAware { SCNDataCache().setChannelCache(channels); // no await } - final (npt, newItems) = await APIClient.getMessageList(acc, thisPageToken, pageSize: cfg.messagePageSize); + final (npt, newItems) = await APIClient.getMessageList(acc, thisPageToken, pageSize: cfg.messagePageSize, filter: _getFilter()); SCNDataCache().addToMessageCache(newItems); // no await @@ -267,16 +267,28 @@ class _MessageListPageState extends State with RouteAware { child: InputChip( avatar: Icon(chiplet.icon()), label: Text(chiplet.label), - onDeleted: () => setState(() => _filterChiplets.remove(chiplet)), + onDeleted: () => _onRemFilter(chiplet), onPressed: () {/* TODO idk what to do here ? */}, visualDensity: VisualDensity(horizontal: -4, vertical: -4), ), ); } - void _onAppBarSearch(String str) { + void _onAddFilter(List remTypeList, List chiplets) { setState(() { - _filterChiplets = _filterChiplets.where((element) => false).toList() + [MessageFilterChiplet(label: str, value: str, type: MessageFilterChipletType.search)]; + final remTypes = remTypeList.toSet(); + + _filterChiplets = _filterChiplets.where((element) => !remTypes.contains(element.type)).toList() + chiplets; + + _pagingController.refresh(); + }); + } + + void _onRemFilter(MessageFilterChiplet chiplet) { + setState(() { + _filterChiplets.remove(chiplet); + + _pagingController.refresh(); }); } @@ -285,4 +297,35 @@ class _MessageListPageState extends State with RouteAware { _pagingController.itemList = [msg] + (_pagingController.itemList ?? []); }); } + + MessageFilter _getFilter() { + var filter = MessageFilter(); + + var chipletsChannel = _filterChiplets.where((p) => p.type == MessageFilterChipletType.channel).toList(); + if (chipletsChannel.isNotEmpty) { + filter.channelIDs = chipletsChannel.map((p) => p.value as String).toList(); + } + + var chipletsSearch = _filterChiplets.where((p) => p.type == MessageFilterChipletType.search).toList(); + if (chipletsSearch.isNotEmpty) { + filter.searchFilter = chipletsSearch.map((p) => p.value as String).first; + } + + var chipletsKeyTokens = _filterChiplets.where((p) => p.type == MessageFilterChipletType.sendkey).toList(); + if (chipletsKeyTokens.isNotEmpty) { + filter.usedKeys = chipletsKeyTokens.map((p) => p.value as String).toList(); + } + + var chipletPriority = _filterChiplets.where((p) => p.type == MessageFilterChipletType.priority).toList(); + if (chipletPriority.isNotEmpty) { + filter.priority = chipletPriority.map((p) => p.value as int).toList(); + } + + var chipletSender = _filterChiplets.where((p) => p.type == MessageFilterChipletType.sender).toList(); + if (chipletSender.isNotEmpty) { + filter.senderNames = chipletSender.map((p) => p.value as String).toList(); + } + + return filter; + } } diff --git a/flutter/lib/state/app_events.dart b/flutter/lib/state/app_events.dart index 5b5a74a..b688290 100644 --- a/flutter/lib/state/app_events.dart +++ b/flutter/lib/state/app_events.dart @@ -1,4 +1,5 @@ import 'package:simplecloudnotifier/models/scn_message.dart'; +import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart'; import 'package:simplecloudnotifier/state/application_log.dart'; class AppEvents { @@ -10,25 +11,30 @@ class AppEvents { AppEvents._internal() {} - List _searchListeners = []; - List _messageReceivedListeners = []; + // -------------------------------------------------------------------------- - void subscribeSearchListener(void Function(String) listener) { - _searchListeners.add(listener); + List types, List)> _filterListeners = []; + + void subscribeFilterListener(void Function(List types, List) listener) { + _filterListeners.add(listener); } - void unsubscribeSearchListener(void Function(String) listener) { - _searchListeners.remove(listener); + void unsubscribeFilterListener(void Function(List types, List) listener) { + _filterListeners.remove(listener); } - void notifySearchListeners(String query) { - ApplicationLog.debug('[AppEvents] onSearch: $query'); + void notifyFilterListeners(List types, List query) { + ApplicationLog.debug('[AppEvents] onFilter: [${types.join(" ; ")}], [${query.map((e) => e.label).join('|')}]'); - for (var listener in _searchListeners) { - listener(query); + for (var listener in _filterListeners) { + listener(types, query); } } + // -------------------------------------------------------------------------- + + List _messageReceivedListeners = []; + void subscribeMessageReceivedListener(void Function(SCNMessage) listener) { _messageReceivedListeners.add(listener); } diff --git a/flutter/lib/types/immediate_future.dart b/flutter/lib/types/immediate_future.dart index 7953e46..07e65a2 100644 --- a/flutter/lib/types/immediate_future.dart +++ b/flutter/lib/types/immediate_future.dart @@ -1,18 +1,26 @@ // This class is useful togther with FutureBuilder // Unfortunately Future.value(x) in FutureBuilder always results in one frame were snapshot.connectionState is waiting -// Whit way we can set the ImmediateFuture.value directly and circumvent that. +// This way we can set the ImmediateFuture.value directly and circumvent that. class ImmediateFuture { final Future future; final T? value; + T? _futureValue = null; + ImmediateFuture(this.future, this.value); ImmediateFuture.ofFuture(Future v) : future = v, - value = null; + value = null { + future.then((v) => _futureValue = v); + } ImmediateFuture.ofValue(T v) : future = Future.value(v), value = v; + + T? get() { + return value ?? _futureValue; + } } diff --git a/scnserver/api/handler/apiSenderNames.go b/scnserver/api/handler/apiSenderNames.go new file mode 100644 index 0000000..1213b6a --- /dev/null +++ b/scnserver/api/handler/apiSenderNames.go @@ -0,0 +1,87 @@ +package handler + +import ( + "blackforestbytes.com/simplecloudnotifier/logic" + "blackforestbytes.com/simplecloudnotifier/models" + "gogs.mikescher.com/BlackForestBytes/goext/ginext" +) + +// ListUserSenderNames swaggerdoc +// +// @Summary List sender-names (of allthe messages of this user) +// @ID api-usersendernames-list +// @Tags API-v2 +// +// @Param uid path string true "UserID" +// +// @Success 200 {object} handler.ListUserKeys.response +// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid" +// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions" +// @Failure 404 {object} ginresp.apiError "message not found" +// @Failure 500 {object} ginresp.apiError "internal server error" +// +// @Router /api/v2/users/{uid}/keys [GET] +func (h APIHandler) ListUserSenderNames(pctx ginext.PreContext) ginext.HTTPResponse { + type uri struct { + UserID models.UserID `uri:"uid" binding:"entityid"` + } + type response struct { + } + + var u uri + ctx, g, errResp := pctx.URI(&u).Start() + if errResp != nil { + return *errResp + } + defer ctx.Cancel() + + return h.app.DoRequest(ctx, g, models.TLockRead, func(ctx *logic.AppContext, finishSuccess func(r ginext.HTTPResponse) ginext.HTTPResponse) ginext.HTTPResponse { + + if permResp := ctx.CheckPermissionUserRead(u.UserID); permResp != nil { + return *permResp + } + + return nil //TODO + + }) +} + +// ListSenderNames swaggerdoc +// +// @Summary List sender-names (of all messages this user can view, eitehr own or foreign-subscribed) +// @ID api-sendernames-list +// @Tags API-v2 +// +// @Success 200 {object} handler.ListSenderNames.response +// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid" +// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions" +// @Failure 404 {object} ginresp.apiError "message not found" +// @Failure 500 {object} ginresp.apiError "internal server error" +// +// @Router /api/v2/sender-names [GET] +func (h APIHandler) ListSenderNames(pctx ginext.PreContext) ginext.HTTPResponse { + type response struct { + } + + ctx, g, errResp := pctx.Start() + if errResp != nil { + return *errResp + } + defer ctx.Cancel() + + return h.app.DoRequest(ctx, g, models.TLockRead, func(ctx *logic.AppContext, finishSuccess func(r ginext.HTTPResponse) ginext.HTTPResponse) ginext.HTTPResponse { + + if permResp := ctx.CheckPermissionAny(); permResp != nil { + return *permResp + } + + userid := *ctx.GetPermissionUserID() + + if permResp := ctx.CheckPermissionUserRead(userid); permResp != nil { + return *permResp + } + + return nil //TODO + + }) +} diff --git a/scnserver/api/router.go b/scnserver/api/router.go index 714a48d..9771c52 100644 --- a/scnserver/api/router.go +++ b/scnserver/api/router.go @@ -152,10 +152,14 @@ func (r *Router) Init(e *ginext.GinWrapper) error { apiv2.DELETE("/users/:uid/subscriptions/:sid").Handle(r.apiHandler.CancelSubscription) apiv2.PATCH("/users/:uid/subscriptions/:sid").Handle(r.apiHandler.UpdateSubscription) + apiv2.GET("/users/:uid/sender-names").Handle(r.apiHandler.ListUserSenderNames) + apiv2.GET("/messages").Handle(r.apiHandler.ListMessages) apiv2.GET("/messages/:mid").Handle(r.apiHandler.GetMessage) apiv2.DELETE("/messages/:mid").Handle(r.apiHandler.DeleteMessage) + apiv2.GET("/sender-names").Handle(r.apiHandler.ListSenderNames) + apiv2.GET("/preview/users/:uid").Handle(r.apiHandler.GetUserPreview) apiv2.GET("/preview/keys/:kid").Handle(r.apiHandler.GetUserKeyPreview) apiv2.GET("/preview/channels/:cid").Handle(r.apiHandler.GetChannelPreview) diff --git a/scnserver/models/channel.go b/scnserver/models/channel.go index 25d1405..7bde908 100644 --- a/scnserver/models/channel.go +++ b/scnserver/models/channel.go @@ -3,9 +3,9 @@ package models type Channel struct { ChannelID ChannelID `db:"channel_id" json:"channel_id"` OwnerUserID UserID `db:"owner_user_id" json:"owner_user_id"` - InternalName string `db:"internal_name" json:"internal_name"` - DisplayName string `db:"display_name" json:"display_name"` - DescriptionName *string `db:"description_name" json:"description_name"` + InternalName string `db:"internal_name" json:"internal_name"` // = InternalName, used for sending, normalized, cannot be changed + DisplayName string `db:"display_name" json:"display_name"` // = DisplayName, used for display purposes, can be changed, initially equals InternalName + DescriptionName *string `db:"description_name" json:"description_name"` // = DescriptionName, (optional), longer description text, initally nil SubscribeKey string `db:"subscribe_key" json:"subscribe_key" jsonfilter:"INCLUDE_KEY"` // can be nil, depending on endpoint TimestampCreated SCNTime `db:"timestamp_created" json:"timestamp_created"` TimestampLastSent *SCNTime `db:"timestamp_lastsent" json:"timestamp_lastsent"` diff --git a/scnserver/test/message_test.go b/scnserver/test/message_test.go index 3ac0256..c6f6a1d 100644 --- a/scnserver/test/message_test.go +++ b/scnserver/test/message_test.go @@ -698,3 +698,163 @@ func TestListMessagesZeroPagesize(t *testing.T) { tt.AssertEqual(t, "msgList.PageSize", 1, msgList1.PageSize) tt.AssertEqual(t, "msgList[0]", "Lorem Ipsum 23", msgList1.Messages[0].Title) } + +func TestListMessagesFilterChannel(t *testing.T) { + ws, baseUrl, stop := tt.StartSimpleWebserver(t) + defer stop() + + data := tt.InitDefaultData(t, ws) + + type msg struct { + ChannelId string `json:"channel_id"` + ChannelInternalName string `json:"channel_internal_name"` + Content string `json:"content"` + MessageId string `json:"message_id"` + OwnerUserId string `json:"owner_user_id"` + Priority int `json:"priority"` + SenderIp string `json:"sender_ip"` + SenderName string `json:"sender_name"` + SenderUserId string `json:"sender_user_id"` + Timestamp string `json:"timestamp"` + Title string `json:"title"` + Trimmed bool `json:"trimmed"` + UsrMessageId string `json:"usr_message_id"` + } + type mglist struct { + Messages []msg `json:"messages"` + } + + cid1 := "" + for _, channel := range data.User[0].Channels { + if channel.InternalName == "Reminders" { + cid1 = channel.ChannelID + } + } + + cid2 := "" + for _, channel := range data.User[0].Channels { + if channel.InternalName == "Chatting Chamber" { + cid2 = channel.ChannelID + } + } + + skey := "" + for _, key := range data.User[0].Keys { + if key.Name == "SendKey (default)" { + skey = key.KeyID + } + } + + akey := "" + for _, key := range data.User[0].Keys { + if key.Name == "AdminKey (default)" { + akey = key.KeyID + } + } + + { + msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?channel=%s", "Reminders,Promotions")) + tt.AssertEqual(t, "msgList.len", 9, len(msgList.Messages)) + } + + { + msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?channel=%s", "Reminders")) + tt.AssertEqual(t, "msgList.len", 6, len(msgList.Messages)) + } + + { + msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?channel_id=%s", cid1)) + tt.AssertEqual(t, "msgList.len", 6, len(msgList.Messages)) + } + + { + msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?channel_id=%s,%s", cid1, cid2)) + tt.AssertEqual(t, "msgList.len", 9, len(msgList.Messages)) + } + + { + msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?filter=%s", "unusual")) + tt.AssertEqual(t, "msgList.len", 1, len(msgList.Messages)) + } + + { + msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?filter=%s", "your")) + tt.AssertEqual(t, "msgList.len", 7, len(msgList.Messages)) + } + + { + msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?priority=%s", "1")) + tt.AssertEqual(t, "msgList.len", 4, len(msgList.Messages)) + } + + { + msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?priority=%s", "2")) + tt.AssertEqual(t, "msgList.len", 6, len(msgList.Messages)) + } + + { + msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?priority=%s", "0")) + tt.AssertEqual(t, "msgList.len", 5, len(msgList.Messages)) + } + + { + msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?priority=%s", "0,2")) + tt.AssertEqual(t, "msgList.len", 11, len(msgList.Messages)) + } + + { + msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?used_key_id=%s", akey)) + tt.AssertEqual(t, "msgList.len", 11, len(msgList.Messages)) + } + + { + msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?used_key_id=%s", skey)) + tt.AssertEqual(t, "msgList.len", 11, len(msgList.Messages)) + } + + { + msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?used_key_id=%s,%s", akey, skey)) + tt.AssertEqual(t, "msgList.len", 22, len(msgList.Messages)) + } + + { + msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?used_key_id=%s&priority=%d", akey, 0)) + tt.AssertEqual(t, "msgList.len", 5, len(msgList.Messages)) + } + + { + msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?sender=%s", "Mobile Mate")) + tt.AssertEqual(t, "msgList.len", 3, len(msgList.Messages)) + } + + { + msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?sender=%s", "Pocket Pal")) + tt.AssertEqual(t, "msgList.len", 3, len(msgList.Messages)) + } + + { + msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?sender=%s,%s", "Pocket Pal", "Mobile Mate")) + tt.AssertEqual(t, "msgList.len", 6, len(msgList.Messages)) + } + + { + msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?sender=%s", "")) + tt.AssertEqual(t, "msgList.len", 12, len(msgList.Messages)) + } + + { + msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?before=%s", time.Now().Add(-time.Hour))) + tt.AssertEqual(t, "msgList.len", 2, len(msgList.Messages)) + } + + { + msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?after=%s", time.Now().Add(-time.Hour))) + tt.AssertEqual(t, "msgList.len", 20, len(msgList.Messages)) + } + + { + msgList := tt.RequestAuthGet[mglist](t, data.User[0].AdminKey, baseUrl, fmt.Sprintf("/api/v2/messages?after=%s", time.Now().Add(5*time.Minute))) + tt.AssertEqual(t, "msgList.len", 3, len(msgList.Messages)) + } + +} diff --git a/scnserver/test/sendername_test.go b/scnserver/test/sendername_test.go new file mode 100644 index 0000000..40e2011 --- /dev/null +++ b/scnserver/test/sendername_test.go @@ -0,0 +1,11 @@ +package test + +import "testing" + +func TestListSenderNames(t *testing.T) { + t.Fail() +} + +func TestListUserSenderNames(t *testing.T) { + t.Fail() +} diff --git a/scnserver/test/util/factory.go b/scnserver/test/util/factory.go index f642369..81d257f 100644 --- a/scnserver/test/util/factory.go +++ b/scnserver/test/util/factory.go @@ -59,15 +59,25 @@ type clientex struct { FCMTok string } +type ChanData struct { + ChannelID string + InternalName string +} + +type KeyDat struct { + KeyID string + Name string +} + type Userdat struct { UID string SendKey string AdminKey string ReadKey string Clients []string - Channels []string + Channels []ChanData Messages []string - Keys []string + Keys []KeyDat Subscriptions []string } @@ -419,26 +429,28 @@ func InitDefaultData(t *testing.T, ws *logic.Application) DefData { for i, usr := range users { type schan struct { - ID string `json:"channel_id"` + ID string `json:"channel_id"` + InternalName string `json:"internal_name"` } type chanlist struct { Channels []schan `json:"channels"` } r0 := RequestAuthGet[chanlist](t, usr.AdminKey, baseUrl, fmt.Sprintf("/api/v2/users/%s/channels?selector=%s", usr.UID, "owned")) - users[i].Channels = langext.ArrMap(r0.Channels, func(v schan) string { return v.ID }) + users[i].Channels = langext.ArrMap(r0.Channels, func(v schan) ChanData { return ChanData{ChannelID: v.ID, InternalName: v.InternalName} }) } // list keys for i, usr := range users { type skey struct { - ID string `json:"keytoken_id"` + ID string `json:"keytoken_id"` + Name string `json:"name"` } type keylist struct { Keys []skey `json:"keys"` } r0 := RequestAuthGet[keylist](t, usr.AdminKey, baseUrl, fmt.Sprintf("/api/v2/users/%s/keys", usr.UID)) - users[i].Keys = langext.ArrMap(r0.Keys, func(v skey) string { return v.ID }) + users[i].Keys = langext.ArrMap(r0.Keys, func(v skey) KeyDat { return KeyDat{KeyID: v.ID, KeyName: v.Name} }) } // list subscriptions