From 63bc71c4055f13d6f22605de3bf3a971254066d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Fri, 18 Apr 2025 00:11:01 +0200 Subject: [PATCH] Implement proper handling for inactive/active subscriptions --- flutter/lib/api/api_client.dart | 26 ++++ flutter/lib/models/subscription.dart | 3 + .../channel_list/channel_list_extended.dart | 4 +- .../pages/channel_list/channel_list_item.dart | 110 ++++++++++++--- ...annel_scanner_result_channelsubscribe.dart | 6 +- .../channel_scanner_result_channelview.dart | 6 +- .../lib/pages/channel_view/channel_view.dart | 129 +++++++++++++++--- .../pages/keytoken_view/keytoken_view.dart | 6 +- flutter/lib/utils/dialogs.dart | 22 +++ flutter/lib/utils/ui.dart | 4 +- 10 files changed, 268 insertions(+), 48 deletions(-) diff --git a/flutter/lib/api/api_client.dart b/flutter/lib/api/api_client.dart index 50ed8a5..7ce2dd9 100644 --- a/flutter/lib/api/api_client.dart +++ b/flutter/lib/api/api_client.dart @@ -524,6 +524,32 @@ class APIClient { ); } + static Future activateSubscription(TokenSource auth, String channelID, String subID) async { + return await _request( + name: 'activateSubscription', + method: 'PATCH', + relURL: 'users/${auth.getUserID()}/subscriptions/${subID}', + jsonBody: { + 'active': true, + }, + fn: Subscription.fromJson, + authToken: auth.getToken(), + ); + } + + static Future deactivateSubscription(TokenSource auth, String channelID, String subID) async { + return await _request( + name: 'deactivateSubscription', + method: 'PATCH', + relURL: 'users/${auth.getUserID()}/subscriptions/${subID}', + jsonBody: { + 'active': false, + }, + fn: Subscription.fromJson, + authToken: auth.getToken(), + ); + } + static Future sendMessage(String userid, String keytoken, String text, {String? channel, String? content, String? messageID, int? priority, String? senderName, DateTime? timestamp}) async { return await _request( name: 'sendMessage', diff --git a/flutter/lib/models/subscription.dart b/flutter/lib/models/subscription.dart index f5ff16e..7af646b 100644 --- a/flutter/lib/models/subscription.dart +++ b/flutter/lib/models/subscription.dart @@ -6,6 +6,7 @@ class Subscription { final String channelInternalName; final String timestampCreated; final bool confirmed; + final bool active; const Subscription({ required this.subscriptionID, @@ -15,6 +16,7 @@ class Subscription { required this.channelInternalName, required this.timestampCreated, required this.confirmed, + required this.active, }); factory Subscription.fromJson(Map json) { @@ -26,6 +28,7 @@ class Subscription { channelInternalName: json['channel_internal_name'] as String, timestampCreated: json['timestamp_created'] as String, confirmed: json['confirmed'] as bool, + active: json['active'] as bool, ); } diff --git a/flutter/lib/pages/channel_list/channel_list_extended.dart b/flutter/lib/pages/channel_list/channel_list_extended.dart index 694b459..cc56953 100644 --- a/flutter/lib/pages/channel_list/channel_list_extended.dart +++ b/flutter/lib/pages/channel_list/channel_list_extended.dart @@ -145,11 +145,11 @@ class _ChannelListExtendedPageState extends State with ), ), floatingActionButton: FloatingActionButton( - heroTag: 'fab_channel_list_qr', + heroTag: 'fab_channel_list_extended-plus', onPressed: () { Navi.push(context, () => ChannelScannerPage()); }, - child: const Icon(FontAwesomeIcons.qrcode), + child: const Icon(FontAwesomeIcons.plus), ), ); } diff --git a/flutter/lib/pages/channel_list/channel_list_item.dart b/flutter/lib/pages/channel_list/channel_list_item.dart index b43fb34..a07d8ec 100644 --- a/flutter/lib/pages/channel_list/channel_list_item.dart +++ b/flutter/lib/pages/channel_list/channel_list_item.dart @@ -138,32 +138,68 @@ class _ChannelListItemState extends State { } Widget _buildIcon(BuildContext context) { - if (widget.subscription == null) { - Widget result = Icon(FontAwesomeIcons.solidSquareDashed, color: Theme.of(context).colorScheme.outline, size: 32); // not-subscribed + final acc = AppAuth(); + + if (widget.subscription == null && widget.channel.ownerUserID == acc.userID) { + // not-subscribed (own channel) + Widget result = Icon(FontAwesomeIcons.solidSquareRss, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3), size: 32); result = GestureDetector(onTap: () => _subscribe(), child: result); return result; - } else if (widget.subscription!.confirmed && widget.channel.ownerUserID == widget.subscription!.subscriberUserID) { - Widget result = Icon(FontAwesomeIcons.solidSquareRss, color: Theme.of(context).colorScheme.onPrimaryContainer, size: 32); // subscribed (own channel) - result = GestureDetector(onTap: () => _unsubscribe(widget.subscription!), child: result); - return result; - } else if (widget.subscription!.confirmed) { - Widget result = Icon(FontAwesomeIcons.solidSquareShareNodes, color: Theme.of(context).colorScheme.onPrimaryContainer, size: 32); // subscribed (foreign channel) - result = GestureDetector(onTap: () => _unsubscribe(widget.subscription!), child: result); - return result; - } else { - Widget result = Icon(FontAwesomeIcons.solidSquareEnvelope, color: Theme.of(context).colorScheme.tertiary, size: 32); // requested - result = GestureDetector(onTap: () => _unsubscribe(widget.subscription!), child: result); + } else if (widget.subscription == null) { + // not-subscribed (foreign channel) + Widget result = Icon(FontAwesomeIcons.solidSquareShareNodes, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3), size: 32); return result; + } else if (widget.subscription!.confirmed && !widget.subscription!.active) { + if (widget.channel.ownerUserID == widget.subscription!.subscriberUserID) { + // inactive (own channel) + Widget result = Icon(FontAwesomeIcons.solidSquareRss, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3), size: 32); + result = GestureDetector(onTap: () => _activate(widget.subscription!), child: result); + return result; + } else { + // inactive (foreign channel) + Widget result = Icon(FontAwesomeIcons.solidSquareShareNodes, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3), size: 32); + result = GestureDetector(onTap: () => _activate(widget.subscription!), child: result); + return result; + } + } else if (widget.subscription!.confirmed && widget.subscription!.active) { + if (widget.channel.ownerUserID == widget.subscription!.subscriberUserID) { + // subscribed+active (own channel) + Widget result = Icon(FontAwesomeIcons.solidSquareRss, color: Theme.of(context).colorScheme.onPrimaryContainer, size: 32); + result = GestureDetector(onTap: () => _unsubscribe(widget.subscription!), child: result); + return result; + } else { + // subscribed+active (foreign channel) + Widget result = Icon(FontAwesomeIcons.solidSquareShareNodes, color: Theme.of(context).colorScheme.onPrimaryContainer, size: 32); + result = GestureDetector(onTap: () => _deactivate(widget.subscription!), child: result); + return result; + } + } else if (!widget.subscription!.confirmed) { + if (widget.channel.ownerUserID == widget.subscription!.subscriberUserID) { + // requested (own channel) + return SizedBox(width: 32, height: 32); + } else { + // requested (foreign channel) + Widget result = Icon(FontAwesomeIcons.solidSquareEnvelope, color: Theme.of(context).colorScheme.tertiary, size: 32); + result = GestureDetector(onTap: () => _unsubscribe(widget.subscription!), child: result); + return result; + } } + + // fallback + return SizedBox(width: 32, height: 32); } Widget _buildSubscriptionStateText(BuildContext context) { if (widget.subscription == null) { return Text("", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160))); - } else if (widget.subscription!.confirmed && widget.channel.ownerUserID == widget.subscription!.subscriberUserID) { + } else if (widget.subscription!.confirmed && widget.subscription!.active && widget.channel.ownerUserID == widget.subscription!.subscriberUserID) { return Text("subscribed", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160))); - } else if (widget.subscription!.confirmed) { - return Text("subscripted (foreign channe)", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160))); + } else if (widget.subscription!.confirmed && !widget.subscription!.active && widget.channel.ownerUserID == widget.subscription!.subscriberUserID) { + return Text("inactive (own channel)", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160))); + } else if (widget.subscription!.confirmed && widget.subscription!.active) { + return Text("subscribed & active (foreign channel)", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160))); + } else if (widget.subscription!.confirmed && !widget.subscription!.active) { + return Text("subscribed (foreign channel) (inactive)", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160))); } else { return Text("subscription requested", style: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color?.withAlpha(160))); } @@ -194,12 +230,12 @@ class _ChannelListItemState extends State { void _unsubscribe(Subscription sub) async { final acc = AppAuth(); - if (acc.isAuth() && widget.channel.ownerUserID == acc.getUserID() && widget.subscription != null) { + if (acc.isAuth()) { try { - await APIClient.deleteSubscription(acc, widget.channel.channelID, widget.subscription!.subscriptionID); + await APIClient.deleteSubscription(acc, sub.channelID, sub.subscriptionID); widget.onChannelListReloadTrigger.call(); - widget.onSubscriptionChanged.call(widget.channel.channelID, null); + widget.onSubscriptionChanged.call(sub.channelID, null); Toaster.success("Success", 'Unsubscribed from channel'); } catch (exc, trace) { @@ -208,4 +244,40 @@ class _ChannelListItemState extends State { } } } + + void _deactivate(Subscription sub) async { + final acc = AppAuth(); + + if (acc.isAuth()) { + try { + var newSub = await APIClient.deactivateSubscription(acc, sub.channelID, sub.subscriptionID); + widget.onChannelListReloadTrigger.call(); + + widget.onSubscriptionChanged.call(sub.channelID, newSub); + + Toaster.success("Success", 'Unsubscribed from channel'); + } catch (exc, trace) { + Toaster.error("Error", 'Failed to unsubscribe from channel'); + ApplicationLog.error('Failed to unsubscribe from channel: ' + exc.toString(), trace: trace); + } + } + } + + void _activate(Subscription sub) async { + final acc = AppAuth(); + + if (acc.isAuth()) { + try { + var newSub = await APIClient.activateSubscription(acc, sub.channelID, sub.subscriptionID); + widget.onChannelListReloadTrigger.call(); + + widget.onSubscriptionChanged.call(sub.channelID, newSub); + + Toaster.success("Success", 'Subscribed to channel'); + } catch (exc, trace) { + Toaster.error("Error", 'Failed to subscribe to channel'); + ApplicationLog.error('Failed to subscribe to channel: ' + exc.toString(), trace: trace); + } + } + } } diff --git a/flutter/lib/pages/channel_scanner/channel_scanner_result_channelsubscribe.dart b/flutter/lib/pages/channel_scanner/channel_scanner_result_channelsubscribe.dart index 1b0775a..433e9e8 100644 --- a/flutter/lib/pages/channel_scanner/channel_scanner_result_channelsubscribe.dart +++ b/flutter/lib/pages/channel_scanner/channel_scanner_result_channelsubscribe.dart @@ -190,7 +190,11 @@ class _ChannelScannerResultChannelSubscribeState extends State { icon: FontAwesomeIcons.solidDiagramSubtask, title: 'Subscription (own)', values: [_formatSubscriptionStatus(this.subscription)], - iconActions: isSubscribed ? [(FontAwesomeIcons.solidSquareXmark, _unsubscribe)] : [(FontAwesomeIcons.solidSquareRss, _subscribe)], + iconActions: isSubscribed ? [(FontAwesomeIcons.solidSquareXmark, null, _unsubscribe)] : [(FontAwesomeIcons.solidSquareRss, null, _subscribe)], ), _buildForeignSubscriptions(context), _buildOwnerCard(context, true), @@ -217,7 +219,48 @@ class _ChannelViewPageState extends State { } Widget _buildForeignChannelView(BuildContext context, ChannelPreview channel) { - final isSubscribed = (subscription != null && subscription!.confirmed); + Widget subCard; + + if (subscription != null && subscription!.confirmed && subscription!.active) { + subCard = UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidDiagramSubtask, + title: 'Subscription (foreign)', + values: [_formatSubscriptionStatus(subscription)], + iconActions: [(FontAwesomeIcons.solidSquareXmark, null, _deactivate)], + ); + } else if (subscription != null && subscription!.confirmed && !subscription!.active) { + subCard = UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidDiagramSubtask, + title: 'Subscription (foreign)', + values: [_formatSubscriptionStatus(subscription)], + iconActions: [(FontAwesomeIcons.solidSquareXmark, Colors.red[900], () => _unsubscribe(confirm: 'Really (permantenly) delete your subscription to this channel?')), (FontAwesomeIcons.solidSquareRss, null, _activate)], + ); + } else if (subscription != null && !subscription!.confirmed) { + subCard = UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidDiagramSubtask, + title: 'Subscription (foreign)', + values: [_formatSubscriptionStatus(subscription)], + iconActions: [(FontAwesomeIcons.solidSquareXmark, Colors.red[900], () => _unsubscribe(confirm: 'Really withdraw your subscription-request to this channel?'))], + ); + } else if (subscription == null) { + subCard = UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidDiagramSubtask, + title: 'Subscription (foreign)', + values: [_formatSubscriptionStatus(subscription)], + iconActions: [(FontAwesomeIcons.solidSquareRss, null, _subscribe)], + ); + } else { + subCard = UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidDiagramSubtask, + title: 'Subscription (foreign)', + values: [_formatSubscriptionStatus(subscription)], + ); + } return SingleChildScrollView( child: Padding( @@ -240,15 +283,16 @@ class _ChannelViewPageState extends State { ), _buildDisplayNameCard(context, false), _buildDescriptionNameCard(context, false), - UI.metaCard( - context: context, - icon: FontAwesomeIcons.solidDiagramSubtask, - title: 'Subscription (foreign)', - values: [_formatSubscriptionStatus(subscription)], - iconActions: isSubscribed ? [(FontAwesomeIcons.solidSquareXmark, _unsubscribe)] : [(FontAwesomeIcons.solidSquareRss, _subscribe)], - ), + subCard, _buildForeignSubscriptions(context), _buildOwnerCard(context, false), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidEnvelope, + title: 'Messages', + values: [channel.messagesSent.toString()], + mainAction: (subscription != null && subscription!.confirmed) ? () => Navi.push(context, () => FilteredMessageViewPage(title: channel.displayName, filter: MessageFilter(channelIDs: [channel.channelID]))) : null, + ), ], ), ), @@ -269,7 +313,7 @@ class _ChannelViewPageState extends State { icon: FontAwesomeIcons.solidDiagramSuccessor, title: 'Subscription (' + (user?.username ?? user?.userID ?? 'other') + ')', values: [_formatSubscriptionStatus(sub)], - iconActions: _getForeignSubActions(sub), + iconActions: _getForeignIncomingSubActions(sub), ), ], ); @@ -371,7 +415,7 @@ class _ChannelViewPageState extends State { icon: FontAwesomeIcons.solidInputText, title: 'DisplayName', values: [_displayNameOverride ?? channelPreview!.displayName], - iconActions: isOwned ? [(FontAwesomeIcons.penToSquare, _showEditDisplayName)] : [], + iconActions: isOwned ? [(FontAwesomeIcons.penToSquare, null, _showEditDisplayName)] : [], ); } else if (_editDisplayName == EditState.saving) { return Padding( @@ -427,7 +471,7 @@ class _ChannelViewPageState extends State { icon: FontAwesomeIcons.solidInputPipe, title: 'Description', values: [_descriptionNameOverride ?? channelPreview?.descriptionName ?? ''], - iconActions: isOwned ? [(FontAwesomeIcons.penToSquare, _showEditDescriptionName)] : [], + iconActions: isOwned ? [(FontAwesomeIcons.penToSquare, null, _showEditDescriptionName)] : [], ); } else if (_editDescriptionName == EditState.saving) { return Padding( @@ -536,11 +580,16 @@ class _ChannelViewPageState extends State { } } - void _unsubscribe() async { + void _unsubscribe({String? confirm = null}) async { final acc = AppAuth(); if (subscription == null) return; + if (confirm != null) { + final r = await UIDialogs.showConfirmDialog(context, confirm, okText: 'Unsubscribe', cancelText: 'Cancel'); + if (!r) return; + } + try { await APIClient.deleteSubscription(acc, widget.channelID, subscription!.subscriptionID); widget.needsReload?.call(); @@ -554,6 +603,42 @@ class _ChannelViewPageState extends State { } } + void _deactivate() async { + final acc = AppAuth(); + + if (subscription == null) return; + + try { + await APIClient.deactivateSubscription(acc, widget.channelID, subscription!.subscriptionID); + widget.needsReload?.call(); + + await _initStateAsync(false); + + Toaster.success("Success", 'Unsubscribed from channel'); + } catch (exc, trace) { + Toaster.error("Error", 'Failed to unsubscribe from channel'); + ApplicationLog.error('Failed to unsubscribe from channel: ' + exc.toString(), trace: trace); + } + } + + void _activate() async { + final acc = AppAuth(); + + if (subscription == null) return; + + try { + await APIClient.activateSubscription(acc, widget.channelID, subscription!.subscriptionID); + widget.needsReload?.call(); + + await _initStateAsync(false); + + Toaster.success("Success", 'Subscribed to channel'); + } catch (exc, trace) { + Toaster.error("Error", 'Failed to subscribe to channel'); + ApplicationLog.error('Failed to subscribe to channel: ' + exc.toString(), trace: trace); + } + } + void _cancelForeignSubscription(Subscription sub) async { final acc = AppAuth(); @@ -605,10 +690,14 @@ class _ChannelViewPageState extends State { String _formatSubscriptionStatus(Subscription? subscription) { if (subscription == null) { return 'Not Subscribed'; - } else if (subscription.confirmed) { - return 'Subscribed'; - } else { + } else if (subscription.confirmed && subscription.active) { + return 'Subscribed & Active'; + } else if (subscription.confirmed && !subscription.active) { + return 'Subscribed & Inactive'; + } else if (!subscription.confirmed) { return 'Requested'; + } else { + return '?'; } } @@ -662,13 +751,13 @@ class _ChannelViewPageState extends State { } } - List<(IconData, void Function())> _getForeignSubActions(Subscription sub) { + List<(IconData, Color?, void Function())> _getForeignIncomingSubActions(Subscription sub) { if (sub.confirmed) { - return [(FontAwesomeIcons.solidSquareXmark, () => _cancelForeignSubscription(sub))]; + return [(FontAwesomeIcons.solidSquareXmark, Colors.red[900], () => _cancelForeignSubscription(sub))]; } else { return [ - (FontAwesomeIcons.solidSquareCheck, () => _confirmForeignSubscription(sub)), - (FontAwesomeIcons.solidSquareXmark, () => _denyForeignSubscription(sub)), + (FontAwesomeIcons.solidSquareCheck, Colors.green[900], () => _confirmForeignSubscription(sub)), + (FontAwesomeIcons.solidSquareXmark, Colors.red[900], () => _denyForeignSubscription(sub)), ]; } } diff --git a/flutter/lib/pages/keytoken_view/keytoken_view.dart b/flutter/lib/pages/keytoken_view/keytoken_view.dart index 9e15a1e..9326a7e 100644 --- a/flutter/lib/pages/keytoken_view/keytoken_view.dart +++ b/flutter/lib/pages/keytoken_view/keytoken_view.dart @@ -267,7 +267,7 @@ class _KeyTokenViewPageState extends State { icon: FontAwesomeIcons.solidInputText, title: 'Name', values: [_nameOverride ?? keytokenPreview!.name], - iconActions: isOwned ? [(FontAwesomeIcons.penToSquare, _showEditName)] : [], + iconActions: isOwned ? [(FontAwesomeIcons.penToSquare, null, _showEditName)] : [], ); } else if (_editName == EditState.saving) { return Padding( @@ -357,7 +357,7 @@ class _KeyTokenViewPageState extends State { icon: FontAwesomeIcons.shieldKeyhole, title: 'Permissions', values: _formatPermissions(keyToken.permissions), - iconActions: [(FontAwesomeIcons.penToSquare, _editPermissions)], + iconActions: [(FontAwesomeIcons.penToSquare, null, _editPermissions)], ); } else { w1 = UI.metaCard( @@ -374,7 +374,7 @@ class _KeyTokenViewPageState extends State { icon: FontAwesomeIcons.solidSnake, title: 'Channels', values: (keyToken.allChannels) ? ['All Channels'] : keyToken.channels, //TODO show channel names - iconActions: [(FontAwesomeIcons.penToSquare, _editChannels)], + iconActions: [(FontAwesomeIcons.penToSquare, null, _editChannels)], ); } else { w2 = UI.metaCard( diff --git a/flutter/lib/utils/dialogs.dart b/flutter/lib/utils/dialogs.dart index 6634909..83db8d4 100644 --- a/flutter/lib/utils/dialogs.dart +++ b/flutter/lib/utils/dialogs.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; class UIDialogs { + static Future showTextInput(BuildContext context, String title, String hintText) { var _textFieldController = TextEditingController(); @@ -26,4 +27,25 @@ class UIDialogs { ), ); } + + static Future showConfirmDialog(BuildContext context, String title, {String? text, String? okText, String? cancelText}) { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: (text != null) ? Text(text) : null, + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(cancelText ?? 'Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(okText ?? 'OK'), + ), + ], + ), + ).then((value) => value ?? false); + } + } diff --git a/flutter/lib/utils/ui.dart b/flutter/lib/utils/ui.dart index 3a9bcb1..4f60dbb 100644 --- a/flutter/lib/utils/ui.dart +++ b/flutter/lib/utils/ui.dart @@ -124,7 +124,7 @@ class UI { ); } - static Widget metaCard({required BuildContext context, required IconData icon, required String title, required List values, void Function()? mainAction, List<(IconData, void Function())>? iconActions}) { + static Widget metaCard({required BuildContext context, required IconData icon, required String title, required List values, void Function()? mainAction, List<(IconData, Color?, void Function())>? iconActions}) { final container = UI.box( context: context, padding: EdgeInsets.fromLTRB(16, 2, 4, 2), @@ -145,7 +145,7 @@ class UI { SizedBox(width: 12), for (final iconAction in iconActions) ...[ SizedBox(width: 4), - IconButton(icon: FaIcon(iconAction.$1), onPressed: iconAction.$2), + IconButton(icon: FaIcon(iconAction.$1), color: iconAction.$2, onPressed: iconAction.$3), ], ], ],