diff --git a/flutter/lib/api/api_client.dart b/flutter/lib/api/api_client.dart index 7ce2dd9..5de81c4 100644 --- a/flutter/lib/api/api_client.dart +++ b/flutter/lib/api/api_client.dart @@ -332,11 +332,26 @@ class APIClient { ); } + static Future getSubscription(TokenSource auth, String subscriptionID) async { + return await _request( + name: 'getSubscription', + method: 'GET', + relURL: 'users/${auth.getUserID()}/subscriptions/${subscriptionID}', + fn: Subscription.fromJson, + authToken: auth.getToken(), + ); + } + static Future> getSubscriptionList(TokenSource auth) async { return await _request( name: 'getSubscriptionList', method: 'GET', relURL: 'users/${auth.getUserID()}/subscriptions', + query: { + 'direction': ['both'], + 'confirmation': ['all'], + 'external': ['all'], + }, fn: (json) => Subscription.fromJsonArray(json['subscriptions'] as List), authToken: auth.getToken(), ); diff --git a/flutter/lib/components/modals/filter_modal_channel.dart b/flutter/lib/components/modals/filter_modal_channel.dart index 8137fd2..b019e93 100644 --- a/flutter/lib/components/modals/filter_modal_channel.dart +++ b/flutter/lib/components/modals/filter_modal_channel.dart @@ -6,6 +6,7 @@ import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.da import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_events.dart'; import 'package:simplecloudnotifier/types/immediate_future.dart'; +import 'package:simplecloudnotifier/utils/navi.dart'; class FilterModalChannel extends StatefulWidget { @override @@ -83,7 +84,7 @@ class _FilterModalChannelState extends State { } void onOkay() { - Navigator.of(context).pop(); + Navi.popDialog(context); final chiplets = _selectedEntries .map((e) => MessageFilterChiplet( diff --git a/flutter/lib/components/modals/filter_modal_keytoken.dart b/flutter/lib/components/modals/filter_modal_keytoken.dart index b2af4d4..270abc9 100644 --- a/flutter/lib/components/modals/filter_modal_keytoken.dart +++ b/flutter/lib/components/modals/filter_modal_keytoken.dart @@ -6,6 +6,7 @@ import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.da import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_events.dart'; import 'package:simplecloudnotifier/types/immediate_future.dart'; +import 'package:simplecloudnotifier/utils/navi.dart'; class FilterModalKeytoken extends StatefulWidget { @override @@ -83,7 +84,7 @@ class _FilterModalKeytokenState extends State { } void onOkay() { - Navigator.of(context).pop(); + Navi.popDialog(context); final chiplets = _selectedEntries .map((e) => MessageFilterChiplet( diff --git a/flutter/lib/components/modals/filter_modal_priority.dart b/flutter/lib/components/modals/filter_modal_priority.dart index b6fe67c..bcafd2a 100644 --- a/flutter/lib/components/modals/filter_modal_priority.dart +++ b/flutter/lib/components/modals/filter_modal_priority.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart'; import 'package:simplecloudnotifier/state/app_events.dart'; +import 'package:simplecloudnotifier/utils/navi.dart'; class FilterModalPriority extends StatefulWidget { @override @@ -58,7 +59,7 @@ class _FilterModalPriorityState extends State { } void onOkay() { - Navigator.of(context).pop(); + Navi.popDialog(context); final chiplets = _selectedEntries.map((e) => MessageFilterChiplet(label: _texts[e]?.$2 ?? '???', value: e, type: MessageFilterChipletType.priority)).toList(); diff --git a/flutter/lib/components/modals/filter_modal_searchplain.dart b/flutter/lib/components/modals/filter_modal_searchplain.dart index 28f60c6..84655ea 100644 --- a/flutter/lib/components/modals/filter_modal_searchplain.dart +++ b/flutter/lib/components/modals/filter_modal_searchplain.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:simplecloudnotifier/pages/message_list/message_filter_chiplet.dart'; import 'package:simplecloudnotifier/state/app_events.dart'; +import 'package:simplecloudnotifier/utils/navi.dart'; class FilterModalSearchPlain extends StatefulWidget { @override @@ -44,7 +45,7 @@ class _FilterModalSearchPlainState extends State { } void _onOkay() { - Navigator.of(context).pop(); + Navi.popDialog(context); List chiplets = []; if (_controller.text.isNotEmpty) { diff --git a/flutter/lib/components/modals/filter_modal_time.dart b/flutter/lib/components/modals/filter_modal_time.dart index 1315020..dab70b1 100644 --- a/flutter/lib/components/modals/filter_modal_time.dart +++ b/flutter/lib/components/modals/filter_modal_time.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:simplecloudnotifier/utils/navi.dart'; class FilterModalTime extends StatefulWidget { @override @@ -36,7 +37,7 @@ class _FilterModalTimeState extends State { } void onOkay() { - Navigator.of(context).pop(); + Navi.popDialog(context); //TODO } diff --git a/flutter/lib/pages/account/account.dart b/flutter/lib/pages/account/account.dart index d2d42bf..bdacaad 100644 --- a/flutter/lib/pages/account/account.dart +++ b/flutter/lib/pages/account/account.dart @@ -12,6 +12,7 @@ import 'package:simplecloudnotifier/pages/client_list/client_list.dart'; import 'package:simplecloudnotifier/pages/filtered_message_view/filtered_message_view.dart'; import 'package:simplecloudnotifier/pages/keytoken_list/keytoken_list.dart'; import 'package:simplecloudnotifier/pages/sender_list/sender_list.dart'; +import 'package:simplecloudnotifier/pages/subscription_list/subscription_list.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/application_log.dart'; import 'package:simplecloudnotifier/state/globals.dart'; @@ -391,19 +392,11 @@ class _AccountRootPageState extends State { List _buildCards(BuildContext context, User user) { return [ - _buildNumberCard(context, 'Subscription', 's', futureSubscriptionCount, () {/*TODO*/}), - _buildNumberCard(context, 'Client', 's', futureClientCount, () { - Navi.push(context, () => ClientListPage()); - }), - _buildNumberCard(context, 'Key', 's', futureKeyCount, () { - Navi.push(context, () => KeyTokenListPage()); - }), - _buildNumberCard(context, 'Channel', 's', futureChannelAllCount, () { - Navi.push(context, () => ChannelListExtendedPage()); - }), - _buildNumberCard(context, 'Sender', '', futureSenderNamesCount, () { - Navi.push(context, () => SenderListPage()); - }), + _buildNumberCard(context, 'Subscription', 's', futureSubscriptionCount, () => Navi.push(context, () => SubscriptionListPage())), + _buildNumberCard(context, 'Client', 's', futureClientCount, () => Navi.push(context, () => ClientListPage())), + _buildNumberCard(context, 'Key', 's', futureKeyCount, () => Navi.push(context, () => KeyTokenListPage())), + _buildNumberCard(context, 'Channel', 's', futureChannelAllCount, () => Navi.push(context, () => ChannelListExtendedPage())), + _buildNumberCard(context, 'Sender', '', futureSenderNamesCount, () => Navi.push(context, () => SenderListPage())), UI.buttonCard( context: context, margin: EdgeInsets.fromLTRB(0, 4, 0, 4), diff --git a/flutter/lib/pages/channel_list/channel_list_item.dart b/flutter/lib/pages/channel_list/channel_list_item.dart index a07d8ec..06ca685 100644 --- a/flutter/lib/pages/channel_list/channel_list_item.dart +++ b/flutter/lib/pages/channel_list/channel_list_item.dart @@ -142,22 +142,22 @@ class _ChannelListItemState extends State { 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); + Widget result = Icon(FontAwesomeIcons.solidSquareRss, color: Theme.of(context).colorScheme.outline.withAlpha(75), size: 32); result = GestureDetector(onTap: () => _subscribe(), child: result); return 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); + Widget result = Icon(FontAwesomeIcons.solidSquareShareNodes, color: Theme.of(context).colorScheme.outline.withAlpha(75), 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); + Widget result = Icon(FontAwesomeIcons.solidSquareRss, color: Theme.of(context).colorScheme.outline.withAlpha(75), 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); + Widget result = Icon(FontAwesomeIcons.solidSquareShareNodes, color: Theme.of(context).colorScheme.outline.withAlpha(75), size: 32); result = GestureDetector(onTap: () => _activate(widget.subscription!), child: result); return result; } diff --git a/flutter/lib/pages/channel_view/channel_view.dart b/flutter/lib/pages/channel_view/channel_view.dart index df721c8..677552e 100644 --- a/flutter/lib/pages/channel_view/channel_view.dart +++ b/flutter/lib/pages/channel_view/channel_view.dart @@ -10,6 +10,7 @@ import 'package:simplecloudnotifier/models/subscription.dart'; import 'package:simplecloudnotifier/models/user.dart'; import 'package:simplecloudnotifier/pages/channel_message_view/channel_message_view.dart'; import 'package:simplecloudnotifier/pages/filtered_message_view/filtered_message_view.dart'; +import 'package:simplecloudnotifier/pages/subscription_view/subscription_view.dart'; import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart'; import 'package:simplecloudnotifier/state/application_log.dart'; @@ -199,6 +200,7 @@ class _ChannelViewPageState extends State { title: 'Subscription (own)', values: [_formatSubscriptionStatus(this.subscription)], iconActions: isSubscribed ? [(FontAwesomeIcons.solidSquareXmark, null, _unsubscribe)] : [(FontAwesomeIcons.solidSquareRss, null, _subscribe)], + mainAction: () => Navi.push(context, () => SubscriptionViewPage(subscriptionID: subscription!.subscriptionID, preloadedData: (subscription, null, null, null), needsReload: widget.needsReload)), ), _buildForeignSubscriptions(context), _buildOwnerCard(context, true), @@ -228,6 +230,7 @@ class _ChannelViewPageState extends State { title: 'Subscription (foreign)', values: [_formatSubscriptionStatus(subscription)], iconActions: [(FontAwesomeIcons.solidSquareXmark, null, _deactivate)], + mainAction: () => Navi.push(context, () => SubscriptionViewPage(subscriptionID: subscription!.subscriptionID, preloadedData: (subscription, null, null, null), needsReload: widget.needsReload)), ); } else if (subscription != null && subscription!.confirmed && !subscription!.active) { subCard = UI.metaCard( @@ -236,6 +239,7 @@ class _ChannelViewPageState extends State { 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)], + mainAction: () => Navi.push(context, () => SubscriptionViewPage(subscriptionID: subscription!.subscriptionID, preloadedData: (subscription, null, null, null), needsReload: widget.needsReload)), ); } else if (subscription != null && !subscription!.confirmed) { subCard = UI.metaCard( @@ -244,6 +248,7 @@ class _ChannelViewPageState extends State { title: 'Subscription (foreign)', values: [_formatSubscriptionStatus(subscription)], iconActions: [(FontAwesomeIcons.solidSquareXmark, Colors.red[900], () => _unsubscribe(confirm: 'Really withdraw your subscription-request to this channel?'))], + mainAction: () => Navi.push(context, () => SubscriptionViewPage(subscriptionID: subscription!.subscriptionID, preloadedData: (subscription, null, null, null), needsReload: widget.needsReload)), ); } else if (subscription == null) { subCard = UI.metaCard( @@ -252,6 +257,7 @@ class _ChannelViewPageState extends State { title: 'Subscription (foreign)', values: [_formatSubscriptionStatus(subscription)], iconActions: [(FontAwesomeIcons.solidSquareRss, null, _subscribe)], + mainAction: () => Navi.push(context, () => SubscriptionViewPage(subscriptionID: subscription!.subscriptionID, preloadedData: (subscription, null, null, null), needsReload: widget.needsReload)), ); } else { subCard = UI.metaCard( @@ -259,6 +265,7 @@ class _ChannelViewPageState extends State { icon: FontAwesomeIcons.solidDiagramSubtask, title: 'Subscription (foreign)', values: [_formatSubscriptionStatus(subscription)], + mainAction: () => Navi.push(context, () => SubscriptionViewPage(subscriptionID: subscription!.subscriptionID, preloadedData: (subscription, null, null, null), needsReload: widget.needsReload)), ); } @@ -314,6 +321,7 @@ class _ChannelViewPageState extends State { title: 'Subscription (' + (user?.username ?? user?.userID ?? 'other') + ')', values: [_formatSubscriptionStatus(sub)], iconActions: _getForeignIncomingSubActions(sub), + mainAction: () => Navi.push(context, () => SubscriptionViewPage(subscriptionID: subscription!.subscriptionID, preloadedData: (subscription, null, null, null), needsReload: widget.needsReload)), ), ], ); diff --git a/flutter/lib/pages/subscription_list/subscription_list.dart b/flutter/lib/pages/subscription_list/subscription_list.dart new file mode 100644 index 0000000..3ba6ba6 --- /dev/null +++ b/flutter/lib/pages/subscription_list/subscription_list.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; +import 'package:provider/provider.dart'; +import 'package:simplecloudnotifier/api/api_client.dart'; +import 'package:simplecloudnotifier/components/layout/scaffold.dart'; +import 'package:simplecloudnotifier/models/channel.dart'; +import 'package:simplecloudnotifier/models/subscription.dart'; +import 'package:simplecloudnotifier/models/user.dart'; +import 'package:simplecloudnotifier/state/application_log.dart'; +import 'package:simplecloudnotifier/state/app_auth.dart'; +import 'package:simplecloudnotifier/pages/subscription_list/subscription_list_item.dart'; +import 'package:simplecloudnotifier/state/scn_data_cache.dart'; + +class SubscriptionListPage extends StatefulWidget { + const SubscriptionListPage({super.key}); + + @override + State createState() => _SubscriptionListPageState(); +} + +class _SubscriptionListPageState extends State { + final PagingController _pagingController = PagingController.fromValue(PagingState(nextPageKey: null, itemList: [], error: null), firstPageKey: 0); + + final userCache = Map(); + final channelCache = Map(); + + @override + void initState() { + super.initState(); + + for (var v in SCNDataCache().getChannelMap().entries) channelCache[v.key] = v.value.toPreview(null); + + _pagingController.addPageRequestListener(_fetchPage); + + _pagingController.refresh(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + } + + @override + void dispose() { + ApplicationLog.debug('SubscriptionListPage::dispose'); + _pagingController.dispose(); + super.dispose(); + } + + Future _fetchPage(int pageKey) async { + final acc = Provider.of(context, listen: false); + + ApplicationLog.debug('Start SubscriptionListPage::_pagingController::_fetchPage [ ${pageKey} ]'); + + if (!acc.isAuth()) { + _pagingController.error = 'Not logged in'; + return; + } + + try { + final items = (await APIClient.getSubscriptionList(acc)).toList(); + + items.sort((a, b) => -1 * a.timestampCreated.compareTo(b.timestampCreated)); + + var promises = Map>(); + + for (var item in items) { + if (userCache[item.subscriberUserID] == null && !promises.containsKey(item.subscriberUserID)) { + promises[item.subscriberUserID] = APIClient.getUserPreview(acc, item.subscriberUserID).then((p) => userCache[p.userID] = p); + } + if (userCache[item.channelOwnerUserID] == null && !promises.containsKey(item.channelOwnerUserID)) { + promises[item.channelOwnerUserID] = APIClient.getUserPreview(acc, item.channelOwnerUserID).then((p) => userCache[p.userID] = p); + } + if (channelCache[item.channelID] == null && !promises.containsKey(item.channelID)) { + channelCache[item.channelID] = await APIClient.getChannelPreview(acc, item.channelID).then((p) => channelCache[p.channelID] = p); + } + } + + await Future.wait(promises.values); + + _pagingController.value = PagingState(nextPageKey: null, itemList: items, error: null); + } catch (exc, trace) { + _pagingController.error = exc.toString(); + ApplicationLog.error('Failed to list subscriptions: ' + exc.toString(), trace: trace); + } + } + + @override + Widget build(BuildContext context) { + return SCNScaffold( + title: "Subscriptions", + showSearch: false, + showShare: false, + child: 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) => SubscriptionListItem(item: item, userCache: userCache, channelCache: channelCache, needsReload: fullRefresh), + ), + ), + ), + ), + ); + } + + void fullRefresh() { + ApplicationLog.debug('SubscriptionListPage::fullRefresh'); + _pagingController.refresh(); + } +} diff --git a/flutter/lib/pages/subscription_list/subscription_list_item.dart b/flutter/lib/pages/subscription_list/subscription_list_item.dart new file mode 100644 index 0000000..c124840 --- /dev/null +++ b/flutter/lib/pages/subscription_list/subscription_list_item.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; +import 'package:simplecloudnotifier/models/channel.dart'; +import 'package:simplecloudnotifier/models/subscription.dart'; +import 'package:simplecloudnotifier/models/user.dart'; +import 'package:simplecloudnotifier/pages/subscription_view/subscription_view.dart'; +import 'package:simplecloudnotifier/state/app_auth.dart'; +import 'package:simplecloudnotifier/utils/navi.dart'; + +enum SubscriptionListItemMode { + Messages, + Extended, +} + +class SubscriptionListItem extends StatelessWidget { + static final _dateFormat = DateFormat('yyyy-MM-dd HH:mm'); //TODO setting + + const SubscriptionListItem({ + required this.item, + required this.userCache, + required this.channelCache, + required this.needsReload, + super.key, + }); + + final Subscription item; + final Map userCache; + final Map channelCache; + + final void Function()? needsReload; + + @override + Widget build(BuildContext context) { + final channelOwner = userCache[item.channelOwnerUserID]; + final subscriber = userCache[item.subscriberUserID]; + final channel = channelCache[item.channelID]; + + return Card.filled( + margin: EdgeInsets.fromLTRB(0, 4, 0, 4), + shape: BeveledRectangleBorder(borderRadius: BorderRadius.circular(0)), + color: Theme.of(context).cardTheme.color, + child: InkWell( + onTap: () { + Navi.push(context, () => SubscriptionViewPage(subscriptionID: item.subscriptionID, preloadedData: (item, channelOwner, subscriber, channel), needsReload: this.needsReload)); + }, + child: Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + Icon(FontAwesomeIcons.solidDiagramSubtask, color: Theme.of(context).colorScheme.outline, size: 32), + SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + subscriber?.username ?? item.subscriberUserID, + style: const TextStyle(), + ), + SizedBox(height: 4), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + "@" + (channel?.displayName ?? item.channelID), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + SizedBox(width: 10), + Text( + "(" + (channelOwner?.username ?? item.channelOwnerUserID) + ")", + style: const TextStyle(fontStyle: FontStyle.italic), + ), + ], + ), + ], + ), + ), + SizedBox(width: 4), + Padding( + padding: const EdgeInsets.all(8), + child: _buildIcon(context), + ), + ], + ), + ), + ), + ); + } + + Widget _buildIcon(BuildContext context) { + final acc = Provider.of(context, listen: false); + + final colorFull = Theme.of(context).colorScheme.onPrimaryContainer; + final colorHalf = Theme.of(context).colorScheme.onPrimaryContainer.withAlpha(75); + + final isOwned = item.channelOwnerUserID == acc.userID && item.subscriberUserID == acc.userID; + final isIncoming = item.channelOwnerUserID == acc.userID && item.subscriberUserID != acc.userID; + final isOutgoing = item.channelOwnerUserID != acc.userID && item.subscriberUserID == acc.userID; + + if (isOutgoing && !item.confirmed) return Icon(FontAwesomeIcons.solidSquareEnvelope, color: colorHalf, size: 24); + if (isOutgoing && !item.active) return Icon(FontAwesomeIcons.solidSquareShareNodes, color: colorHalf, size: 24); + if (isOutgoing && item.active) return Icon(FontAwesomeIcons.solidSquareShareNodes, color: colorFull, size: 24); + + if (isIncoming && !item.confirmed) return Icon(FontAwesomeIcons.solidSquareQuestion, color: colorHalf, size: 24); + if (isIncoming && !item.active) return Icon(FontAwesomeIcons.solidSquareCheck, color: colorHalf, size: 24); + if (isIncoming && item.active) return Icon(FontAwesomeIcons.solidSquareCheck, color: colorFull, size: 24); + + if (isOwned && !item.confirmed) return Icon(FontAwesomeIcons.solidSquare, color: colorHalf, size: 24); // should not be possible + if (isOwned && !item.active) return Icon(FontAwesomeIcons.solidSquareRss, color: colorHalf, size: 24); + if (isOwned && item.active) return Icon(FontAwesomeIcons.solidSquareRss, color: colorFull, size: 24); + + return SizedBox(width: 24, height: 24); // should also not be possible + } +} diff --git a/flutter/lib/pages/subscription_view/subscription_view.dart b/flutter/lib/pages/subscription_view/subscription_view.dart new file mode 100644 index 0000000..9065b40 --- /dev/null +++ b/flutter/lib/pages/subscription_view/subscription_view.dart @@ -0,0 +1,468 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:intl/intl.dart'; +import 'package:simplecloudnotifier/api/api_client.dart'; +import 'package:simplecloudnotifier/components/layout/scaffold.dart'; +import 'package:simplecloudnotifier/models/channel.dart'; +import 'package:simplecloudnotifier/models/subscription.dart'; +import 'package:simplecloudnotifier/models/user.dart'; +import 'package:simplecloudnotifier/pages/channel_view/channel_view.dart'; +import 'package:simplecloudnotifier/state/app_auth.dart'; +import 'package:simplecloudnotifier/state/app_bar_state.dart'; +import 'package:simplecloudnotifier/state/application_log.dart'; +import 'package:simplecloudnotifier/types/immediate_future.dart'; +import 'package:simplecloudnotifier/utils/dialogs.dart'; +import 'package:simplecloudnotifier/utils/navi.dart'; +import 'package:simplecloudnotifier/utils/toaster.dart'; +import 'package:simplecloudnotifier/utils/ui.dart'; +import 'package:provider/provider.dart'; + +class SubscriptionViewPage extends StatefulWidget { + const SubscriptionViewPage({ + required this.subscriptionID, + required this.preloadedData, + required this.needsReload, + super.key, + }); + + final String subscriptionID; + final (Subscription?, UserPreview?, UserPreview?, ChannelPreview?)? preloadedData; + + final void Function()? needsReload; + + @override + State createState() => _SubscriptionViewPageState(); +} + +enum EditState { none, editing, saving } + +enum SubscriptionViewPageInitState { loading, okay, error } + +class _SubscriptionViewPageState extends State { + static final _dateFormat = DateFormat('yyyy-MM-dd HH:mm'); //TODO setting + + late ImmediateFuture _futureChannelOwner; + late ImmediateFuture _futureSubscriber; + late ImmediateFuture _futureChannel; + + int _loadingIndeterminateCounter = 0; + + Subscription? subscription; + + SubscriptionViewPageInitState loadingState = SubscriptionViewPageInitState.loading; + String errorMessage = ''; + + @override + void initState() { + _initStateAsync(true); + + super.initState(); + } + + Future _initStateAsync(bool usePreload) async { + final userAcc = Provider.of(context, listen: false); + + if (widget.preloadedData?.$1 != null && widget.preloadedData!.$1!.subscriptionID == widget.subscriptionID && usePreload) { + subscription = widget.preloadedData!.$1!; + } else { + try { + var r = await APIClient.getSubscription(userAcc, widget.subscriptionID); + setState(() { + subscription = r; + }); + } catch (exc, trace) { + ApplicationLog.error('Failed to load data: ' + exc.toString(), trace: trace); + Toaster.error("Error", 'Failed to load data'); + this.errorMessage = 'Failed to load data: ' + exc.toString(); + this.loadingState = SubscriptionViewPageInitState.error; + return; + } + } + + setState(() { + this.loadingState = SubscriptionViewPageInitState.okay; + + assert(subscription != null); + + if (widget.preloadedData?.$2 != null && widget.preloadedData!.$2!.userID == this.subscription!.channelOwnerUserID && usePreload) { + _futureChannelOwner = ImmediateFuture.ofValue(widget.preloadedData!.$2!); + } else if (widget.preloadedData?.$3 != null && widget.preloadedData!.$3!.userID == this.subscription!.channelOwnerUserID && usePreload) { + _futureChannelOwner = ImmediateFuture.ofValue(widget.preloadedData!.$3!); + } else if (this.subscription!.channelOwnerUserID == userAcc.userID) { + var cacheUser = userAcc.getUserOrNull(); + if (cacheUser != null) { + _futureChannelOwner = ImmediateFuture.ofValue(cacheUser.toPreview()); + } else { + _futureChannelOwner = ImmediateFuture.ofFuture(_getUserPreview(userAcc, this.subscription!.channelOwnerUserID)); + } + } else { + _futureChannelOwner = ImmediateFuture.ofFuture(APIClient.getUserPreview(userAcc, this.subscription!.channelOwnerUserID)); + } + + if (widget.preloadedData?.$2 != null && widget.preloadedData!.$2!.userID == this.subscription!.subscriberUserID && usePreload) { + _futureSubscriber = ImmediateFuture.ofValue(widget.preloadedData!.$2!); + } else if (widget.preloadedData?.$3 != null && widget.preloadedData!.$3!.userID == this.subscription!.subscriberUserID && usePreload) { + _futureSubscriber = ImmediateFuture.ofValue(widget.preloadedData!.$3!); + } else if (this.subscription!.subscriberUserID == userAcc.userID) { + var cacheUser = userAcc.getUserOrNull(); + if (cacheUser != null) { + _futureSubscriber = ImmediateFuture.ofValue(cacheUser.toPreview()); + } else { + _futureSubscriber = ImmediateFuture.ofFuture(_getUserPreview(userAcc, this.subscription!.subscriberUserID)); + } + } else { + _futureSubscriber = ImmediateFuture.ofFuture(APIClient.getUserPreview(userAcc, this.subscription!.subscriberUserID)); + } + + if (widget.preloadedData?.$4 != null && widget.preloadedData!.$4!.channelID == this.subscription!.channelID && usePreload) { + _futureChannel = ImmediateFuture.ofValue(widget.preloadedData!.$4!); + } else { + _futureChannel = ImmediateFuture.ofFuture(APIClient.getChannelPreview(userAcc, this.subscription!.channelID)); + } + }); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final userAcc = Provider.of(context, listen: false); + + Widget child; + + if (loadingState == SubscriptionViewPageInitState.loading) { + child = Center(child: CircularProgressIndicator()); + } else if (loadingState == SubscriptionViewPageInitState.error) { + child = Center(child: Text('Error: ' + errorMessage)); //TODO better error + } else if (loadingState == SubscriptionViewPageInitState.okay) { + if (subscription!.channelOwnerUserID == userAcc.userID && subscription!.subscriberUserID == userAcc.userID) { + child = _buildOwnedSubscriptionView(context, this.subscription!); + } else if (subscription!.channelOwnerUserID == userAcc.userID) { + child = _buildIncomingSubscriptionView(context, this.subscription!); + } else if (subscription!.subscriberUserID == userAcc.userID) { + child = _buildOutgoingSubscriptionView(context, this.subscription!); + } else { + child = Center(child: Text('Error: Invalid subscription state!')); //TODO better error + } + } else { + child = Center(child: Text('Error: page state!')); //TODO better error + } + + return SCNScaffold( + title: "Subscription", + showSearch: false, + showShare: false, + child: child, + ); + } + + Widget _buildOwnedSubscriptionView(BuildContext context, Subscription subscription) { + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(height: 8), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidIdCardClip, + title: 'SubscriptionID', + values: [subscription.subscriptionID], + ), + _buildChannelOwnerCard(context, subscription), + _buildSubscriberCard(context, subscription), + _buildChannelCard(context, subscription), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.clock, + title: 'Created', + values: [_SubscriptionViewPageState._dateFormat.format(DateTime.parse(subscription.timestampCreated).toLocal())], + ), + _buildStatusCard(context), + UI.button(text: "Unsubscribe", onPressed: _unsubscribe, tonal: true), + ], + ), + ), + ); + } + + Widget _buildIncomingSubscriptionView(BuildContext context, Subscription subscription) { + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(height: 8), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidIdCardClip, + title: 'SubscriptionID', + values: [subscription.subscriptionID], + ), + _buildChannelOwnerCard(context, subscription), + _buildSubscriberCard(context, subscription), + _buildChannelCard(context, subscription), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.clock, + title: 'Created', + values: [_SubscriptionViewPageState._dateFormat.format(DateTime.parse(subscription.timestampCreated).toLocal())], + ), + _buildStatusCard(context), + if (subscription.confirmed) UI.button(text: "Revoke subscription", onPressed: _unsubscribe, color: Colors.red), + if (!subscription.confirmed) UI.button(text: "Confirm subscription", onPressed: _confirm, color: Colors.green), + if (!subscription.confirmed) UI.button(text: "Deny subscription", onPressed: _unsubscribe, color: Colors.red), + ], + ), + ), + ); + } + + Widget _buildOutgoingSubscriptionView(BuildContext context, Subscription subscription) { + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(height: 8), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidIdCardClip, + title: 'SubscriptionID', + values: [subscription.subscriptionID], + ), + _buildChannelOwnerCard(context, subscription), + _buildSubscriberCard(context, subscription), + _buildChannelCard(context, subscription), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.clock, + title: 'Created', + values: [_SubscriptionViewPageState._dateFormat.format(DateTime.parse(subscription.timestampCreated).toLocal())], + ), + _buildStatusCard(context), + if (subscription.confirmed && subscription.active) UI.button(text: "Deactivate subscription", onPressed: _deactivate, tonal: true), + if (subscription.confirmed && !subscription.active) UI.button(text: "Activate subscription", onPressed: _activate, tonal: true), + if (subscription.confirmed && !subscription.active) UI.button(text: "Delete subscription", onPressed: () => _unsubscribe(confirm: 'Really (permanently) delete the subscription to this channel?'), color: Colors.red), + if (!subscription.confirmed) UI.button(text: "Cancel subscription request", onPressed: _unsubscribe, tonal: true), + ], + ), + ), + ); + } + + Widget _buildChannelOwnerCard(BuildContext context, Subscription subscription) { + final userAcc = Provider.of(context, listen: false); + + bool isSelf = subscription.channelOwnerUserID == userAcc.userID; + + return FutureBuilder( + future: _futureChannelOwner.future, + builder: (context, snapshot) { + if (snapshot.hasData) { + return UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidUser, + title: 'Channel Owner', + values: [subscription.channelOwnerUserID + (isSelf ? ' (you)' : ''), if (snapshot.data?.username != null) snapshot.data!.username!], + ); + } else { + return UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidUser, + title: 'Channel Owner', + values: [subscription.channelOwnerUserID + (isSelf ? ' (you)' : '')], + ); + } + }, + ); + } + + Widget _buildSubscriberCard(BuildContext context, Subscription subscription) { + final userAcc = Provider.of(context, listen: false); + + bool isSelf = subscription.subscriberUserID == userAcc.userID; + + return FutureBuilder( + future: _futureSubscriber.future, + builder: (context, snapshot) { + if (snapshot.hasData) { + return UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidUser, + title: 'Subscriber', + values: [subscription.subscriberUserID + (isSelf ? ' (you)' : ''), if (snapshot.data?.username != null) snapshot.data!.username!], + ); + } else { + return UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidUser, + title: 'Subscriber', + values: [subscription.subscriberUserID + (isSelf ? ' (you)' : '')], + ); + } + }, + ); + } + + Widget _buildChannelCard(BuildContext context, Subscription subscription) { + return FutureBuilder( + future: _futureChannel.future, + builder: (context, snapshot) { + if (snapshot.hasData) { + return UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidSnake, + title: 'Channel', + values: [subscription.channelID, snapshot.data!.displayName], + mainAction: () => Navi.push(context, () => ChannelViewPage(channelID: subscription.channelID, preloadedData: null, needsReload: null)), + ); + } else { + return UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidSnake, + title: 'Channel', + values: [subscription.channelID, subscription.channelInternalName], + mainAction: () => Navi.push(context, () => ChannelViewPage(channelID: subscription.channelID, preloadedData: null, needsReload: null)), + ); + } + }, + ); + } + + Widget _buildStatusCard(BuildContext context) { + final acc = Provider.of(context, listen: false); + + final item = subscription!; + + final isOwned = item.channelOwnerUserID == acc.userID && item.subscriberUserID == acc.userID; + final isIncoming = item.channelOwnerUserID == acc.userID && item.subscriberUserID != acc.userID; + final isOutgoing = item.channelOwnerUserID != acc.userID && item.subscriberUserID == acc.userID; + + var status = ['ERROR?']; + + if (isOutgoing && !item.confirmed) status = ['Subscription to foreign channel', 'Pending confirmation']; + if (isOutgoing && !item.active) status = ['Subscription to foreign channel', 'Confirmed but inactive']; + if (isOutgoing && item.active) status = ['Subscription to foreign channel', 'Confirmed and active']; + + if (isIncoming && !item.confirmed) status = ['External subscription to your channel', 'Pending confirmation']; + if (isIncoming && !item.active) status = ['External subscription to your channel', 'Deactivated by subscriber']; + if (isIncoming && item.active) status = ['External subscription to your channel', 'Confirmed and active']; + + if (isOwned && !item.confirmed) status = ['Your own channel', 'ERROR']; + if (isOwned && !item.active) status = ['Your own channel', 'Not subscribed']; + if (isOwned && item.active) status = ['Your own channel', 'Active subscription']; + + return UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidInfo, + title: 'Status', + values: status, + ); + } + + Future _getUserPreview(AppAuth auth, String uid) async { + try { + await Future.delayed(const Duration(seconds: 0), () {}); // this is annoyingly important - otherwise we call setLoadingIndeterminate directly in initStat() and get an exception.... + + _incLoadingIndeterminateCounter(1); + + final owner = APIClient.getUserPreview(auth, uid); + + //await Future.delayed(const Duration(seconds: 10), () {}); + + return owner; + } finally { + _incLoadingIndeterminateCounter(-1); + } + } + + void _incLoadingIndeterminateCounter(int delta) { + setState(() { + _loadingIndeterminateCounter += delta; + AppBarState().setLoadingIndeterminate(_loadingIndeterminateCounter > 0); + }); + } + + void _confirm() async { + final acc = AppAuth(); + + if (subscription == null) return; + + try { + await APIClient.confirmSubscription(acc, subscription!.channelID, subscription!.subscriptionID); + widget.needsReload?.call(); + + await _initStateAsync(false); + + Toaster.success("Success", 'Subscription succesfully confirmed'); + } catch (exc, trace) { + Toaster.error("Error", 'Failed to confirm subscription'); + ApplicationLog.error('Failed to confirm subscription: ' + exc.toString(), trace: trace); + } + } + + 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, subscription!.channelID, subscription!.subscriptionID); + widget.needsReload?.call(); + + Toaster.success("Success", 'Unsubscribed from channel'); + Navi.pop(context); + } catch (exc, trace) { + Toaster.error("Error", 'Failed to unsubscribe from channel'); + ApplicationLog.error('Failed to unsubscribe from channel: ' + exc.toString(), trace: trace); + } + } + + void _deactivate() async { + final acc = AppAuth(); + + if (subscription == null) return; + + try { + await APIClient.deactivateSubscription(acc, subscription!.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, subscription!.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); + } + } +} diff --git a/flutter/lib/utils/navi.dart b/flutter/lib/utils/navi.dart index 8339215..a824425 100644 --- a/flutter/lib/utils/navi.dart +++ b/flutter/lib/utils/navi.dart @@ -32,6 +32,10 @@ class Navi { static void popDialog(BuildContext dialogContext) { Navigator.pop(dialogContext); } + + static void pop(BuildContext context) { + Navigator.of(context).pop(); + } } class SCNRouteObserver extends RouteObserver> { diff --git a/flutter/lib/utils/ui.dart b/flutter/lib/utils/ui.dart index 4f60dbb..1b0b779 100644 --- a/flutter/lib/utils/ui.dart +++ b/flutter/lib/utils/ui.dart @@ -130,7 +130,10 @@ class UI { padding: EdgeInsets.fromLTRB(16, 2, 4, 2), child: Row( children: [ - FaIcon(icon, size: 18), + ConstrainedBox( + constraints: new BoxConstraints(minWidth: 18.0), + child: Center(child: FaIcon(icon, size: 18)), + ), SizedBox(width: 16), Expanded( child: Column(