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/error_display/error_display.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 = ErrorDisplay(errorMessage: errorMessage); } 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 = ErrorDisplay(errorMessage: 'Invalid subscription state!'); } } else { child = ErrorDisplay(errorMessage: 'Invalid page state!'); } 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); } } }