From 2b234044619a442afd921eaa74d39e1febdbcd65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Tue, 25 Jun 2024 20:49:40 +0200 Subject: [PATCH] channel_view page --- flutter/lib/api/api_client.dart | 10 + flutter/lib/models/user.dart | 4 + .../pages/channel_list/channel_list_item.dart | 4 +- .../lib/pages/channel_view/channel_view.dart | 336 +++++++++++++----- .../lib/pages/message_view/message_view.dart | 137 +++---- flutter/lib/state/app_auth.dart | 8 + flutter/lib/utils/ui.dart | 45 +++ 7 files changed, 367 insertions(+), 177 deletions(-) diff --git a/flutter/lib/api/api_client.dart b/flutter/lib/api/api_client.dart index ceb3f25..82c6a1e 100644 --- a/flutter/lib/api/api_client.dart +++ b/flutter/lib/api/api_client.dart @@ -247,6 +247,16 @@ class APIClient { ); } + static Future> getChannelSubscriptions(TokenSource auth, String cid) async { + return await _request( + name: 'getChannelSubscriptions', + method: 'GET', + relURL: 'users/${auth.getUserID()}/channels/${cid}/subscriptions', + fn: (json) => Subscription.fromJsonArray(json['subscriptions'] as List), + authToken: auth.getToken(), + ); + } + static Future> getClientList(TokenSource auth) async { return await _request( name: 'getClientList', diff --git a/flutter/lib/models/user.dart b/flutter/lib/models/user.dart index b7f9cfa..f0bd9cb 100644 --- a/flutter/lib/models/user.dart +++ b/flutter/lib/models/user.dart @@ -86,6 +86,10 @@ class User { 'max_user_message_id_length': maxUserMessageIDLength, }; } + + UserPreview toPreview() { + return UserPreview(userID: userID, username: username); + } } class UserWithClientsAndKeys { diff --git a/flutter/lib/pages/channel_list/channel_list_item.dart b/flutter/lib/pages/channel_list/channel_list_item.dart index 5aeb4fc..1047167 100644 --- a/flutter/lib/pages/channel_list/channel_list_item.dart +++ b/flutter/lib/pages/channel_list/channel_list_item.dart @@ -110,8 +110,10 @@ class _ChannelListItemState extends State { Widget _buildIcon(BuildContext context) { if (widget.subscription == null) { return Icon(FontAwesomeIcons.solidSquareDashed, color: Theme.of(context).colorScheme.outline, size: 32); // not-subscribed + } else if (widget.subscription!.confirmed && widget.channel.ownerUserID == widget.subscription!.subscriberUserID) { + return Icon(FontAwesomeIcons.solidSquareRss, color: Theme.of(context).colorScheme.onPrimaryContainer, size: 32); // subscribed (own channel) } else if (widget.subscription!.confirmed) { - return Icon(FontAwesomeIcons.solidSquareRss, color: Theme.of(context).colorScheme.onPrimaryContainer, size: 32); // subscribed + return Icon(FontAwesomeIcons.solidSquareShareNodes, color: Theme.of(context).colorScheme.onPrimaryContainer, size: 32); // subscribed (foreign channel) } else { return Icon(FontAwesomeIcons.solidSquareEnvelope, color: Theme.of(context).colorScheme.tertiary, size: 32); // requested } diff --git a/flutter/lib/pages/channel_view/channel_view.dart b/flutter/lib/pages/channel_view/channel_view.dart index 5a76d92..31857ab 100644 --- a/flutter/lib/pages/channel_view/channel_view.dart +++ b/flutter/lib/pages/channel_view/channel_view.dart @@ -1,21 +1,17 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:intl/intl.dart'; -import 'package:provider/provider.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:share_plus/share_plus.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/keytoken.dart'; -import 'package:simplecloudnotifier/models/scn_message.dart'; import 'package:simplecloudnotifier/models/subscription.dart'; import 'package:simplecloudnotifier/models/user.dart'; import 'package:simplecloudnotifier/state/app_auth.dart'; import 'package:simplecloudnotifier/state/app_bar_state.dart'; -import 'package:simplecloudnotifier/utils/toaster.dart'; +import 'package:simplecloudnotifier/types/immediate_future.dart'; import 'package:simplecloudnotifier/utils/ui.dart'; +import 'package:provider/provider.dart'; class ChannelViewPage extends StatefulWidget { const ChannelViewPage({ @@ -32,10 +28,39 @@ class ChannelViewPage extends StatefulWidget { } class _ChannelViewPageState extends State { - static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm'); + late ImmediateFuture _futureSubscribeKey; + late ImmediateFuture> _futureSubscriptions; + late ImmediateFuture _futureOwner; + + int _loadingIndeterminateCounter = 0; @override void initState() { + final userAcc = Provider.of(context, listen: false); + + if (widget.channel.ownerUserID == userAcc.userID) { + if (widget.channel.subscribeKey != null) { + _futureSubscribeKey = ImmediateFuture.ofValue(widget.channel.subscribeKey); + } else { + _futureSubscribeKey = ImmediateFuture.ofFuture(_getSubScribeKey(userAcc)); + } + _futureSubscriptions = ImmediateFuture>.ofFuture(_listSubscriptions(userAcc)); + } else { + _futureSubscribeKey = ImmediateFuture.ofValue(null); + _futureSubscriptions = ImmediateFuture>.ofValue([]); + } + + if (widget.channel.ownerUserID == userAcc.userID) { + var cacheUser = userAcc.getUserOrNull(); + if (cacheUser != null) { + _futureOwner = ImmediateFuture.ofValue(cacheUser.toPreview()); + } else { + _futureOwner = ImmediateFuture.ofFuture(_getOwner(userAcc)); + } + } else { + _futureOwner = ImmediateFuture.ofFuture(APIClient.getUserPreview(userAcc, widget.channel.ownerUserID)); + } + super.initState(); } @@ -57,109 +82,246 @@ class _ChannelViewPageState extends State { Widget _buildChannelView(BuildContext context) { final userAccUserID = context.select((v) => v.userID); + final isOwned = (widget.channel.ownerUserID == userAccUserID); + final isSubscribed = (widget.subscription != null && widget.subscription!.confirmed); + return SingleChildScrollView( child: Padding( padding: const EdgeInsets.fromLTRB(24, 16, 24, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - ..._buildChannelHeader(context), - SizedBox(height: 8), _buildQRCode(context), SizedBox(height: 8), - //TODO icons - _buildMetaCard(context, FontAwesomeIcons.solidQuestion, 'ChannelID', ['...'], null), - _buildMetaCard(context, FontAwesomeIcons.solidQuestion, 'InternalName', ['...'], null), - _buildMetaCard(context, FontAwesomeIcons.solidQuestion, 'DisplayName', ['...'], null), //TODO edit icon on right to edit name - _buildMetaCard(context, FontAwesomeIcons.solidQuestion, 'Subscription (own)', ['...'], null), //TODO sub/unsub icon on right - //TODO list foreign subscriptions (with accept/decline/delete button on right) - _buildMetaCard(context, FontAwesomeIcons.solidQuestion, 'Messages', ['...'], () {/*TODO*/}), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidIdCardClip, + title: 'ChannelID', + values: [widget.channel.channelID], + ), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidInputNumeric, + title: 'InternalName', + values: [widget.channel.internalName], + ), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidInputText, + title: 'DisplayName', + values: [widget.channel.displayName], + iconActions: isOwned ? [(FontAwesomeIcons.penToSquare, _rename)] : [], + ), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidDiagramSubtask, + title: 'Subscription (own)', + values: [_formatSubscriptionStatus(widget.subscription)], + iconActions: isSubscribed ? [(FontAwesomeIcons.solidSquareXmark, _unsubscribe)] : [(FontAwesomeIcons.solidSquareRss, _subscribe)], + ), + _buildForeignSubscriptions(context), + _buildOwnerCard(context, isOwned), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidEnvelope, + title: 'Messages', + values: [widget.channel.messagesSent.toString()], + mainAction: () {/*TODO*/}, + ), ], ), ), ); } - List _buildChannelHeader(BuildContext context) { - return [ - Text(widget.channel.displayName, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - ]; - } - - Widget _buildMetaCard(BuildContext context, IconData icn, String title, List values, void Function()? action) { - final container = UI.box( - context: context, - padding: EdgeInsets.fromLTRB(16, 2, 4, 2), - child: Row( - children: [ - FaIcon(icn, size: 18), - SizedBox(width: 16), - Column( - crossAxisAlignment: CrossAxisAlignment.start, + Widget _buildForeignSubscriptions(BuildContext context) { + return FutureBuilder( + future: _futureSubscriptions.future, + builder: (context, snapshot) { + if (snapshot.hasData) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), - for (final val in values) Text(val, style: const TextStyle(fontSize: 14)), + for (final sub in snapshot.data!.where((sub) => sub.subscriptionID != widget.subscription?.subscriptionID)) + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidDiagramSuccessor, + title: 'Subscription (other)', + values: [_formatSubscriptionStatus(sub)], + iconActions: _getForignSubActions(sub), + ), ], - ), - ], - ), + ); + } else { + return SizedBox(); + } + }, ); - - if (action == null) { - return Padding( - padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0), - child: container, - ); - } else { - return Padding( - padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0), - child: InkWell( - splashColor: Theme.of(context).splashColor, - onTap: action, - child: container, - ), - ); - } } - String _preformatTitle(SCNMessage message) { - return message.title.replaceAll('\n', '').replaceAll('\r', '').replaceAll('\t', ' '); - } - - String _prettyPrintPriority(int priority) { - switch (priority) { - case 0: - return 'Low (0)'; - case 1: - return 'Normal (1)'; - case 2: - return 'High (2)'; - default: - return 'Unknown ($priority)'; - } + Widget _buildOwnerCard(BuildContext context, bool isOwned) { + return FutureBuilder( + future: _futureOwner.future, + builder: (context, snapshot) { + if (snapshot.hasData) { + return UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidUser, + title: 'Owner', + values: [widget.channel.ownerUserID + (isOwned ? ' (you)' : ''), if (snapshot.data?.username != null) snapshot.data!.username!], + ); + } else { + return UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidUser, + title: 'Owner', + values: [widget.channel.ownerUserID + (isOwned ? ' (you)' : '')], + ); + } + }, + ); } Widget _buildQRCode(BuildContext context) { - var text = 'TODO' + widget.channel.channelID; //TODO subkey+channelid with deeplink-y - return GestureDetector( - onTap: () { - //TODO share + return FutureBuilder( + future: _futureSubscribeKey.future, + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data != null) { + var text = 'TODO' + '\n' + widget.channel.channelID + '\n' + snapshot.data!; //TODO deeplink-y (also perhaps just bas64 everything together?) + return GestureDetector( + onTap: () { + Share.share(text, subject: widget.channel.displayName); + }, + child: Center( + child: QrImageView( + data: text, + version: QrVersions.auto, + size: 300.0, + eyeStyle: QrEyeStyle( + eyeShape: QrEyeShape.square, + color: Theme.of(context).textTheme.bodyLarge?.color, + ), + dataModuleStyle: QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.square, + color: Theme.of(context).textTheme.bodyLarge?.color, + ), + ), + ), + ); + } else if (snapshot.hasData && snapshot.data == null) { + return const SizedBox( + width: 300.0, + height: 300.0, + child: Center(child: Icon(FontAwesomeIcons.solidSnake, size: 64)), + ); + } else { + return const SizedBox( + width: 300.0, + height: 300.0, + child: Center(child: CircularProgressIndicator()), + ); + } }, - child: Center( - child: QrImageView( - data: text, - version: QrVersions.auto, - size: 300.0, - eyeStyle: QrEyeStyle( - eyeShape: QrEyeShape.square, - color: Theme.of(context).textTheme.bodyLarge?.color, - ), - dataModuleStyle: QrDataModuleStyle( - dataModuleShape: QrDataModuleShape.square, - color: Theme.of(context).textTheme.bodyLarge?.color, - ), - ), - ), ); } + + void _rename() { + //TODO + } + + void _subscribe() { + //TODO + } + + void _unsubscribe() { + //TODO + } + + void _cancelForeignSubscription(Subscription sub) { + //TODO + } + + void _confirmForeignSubscription(Subscription sub) { + //TODO + } + + void _denyForeignSubscription(Subscription sub) { + //TODO + } + + String _formatSubscriptionStatus(Subscription? subscription) { + if (subscription == null) { + return 'Not Subscribed'; + } else if (subscription.confirmed) { + return 'Subscribed'; + } else { + return 'Requested'; + } + } + + Future _getSubScribeKey(AppAuth auth) 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); + + var channel = await APIClient.getChannel(auth, widget.channel.channelID); + + //await Future.delayed(const Duration(seconds: 10), () {}); + + return channel.channel.subscribeKey; + } finally { + _incLoadingIndeterminateCounter(-1); + } + } + + Future> _listSubscriptions(AppAuth auth) 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); + + var subs = await APIClient.getChannelSubscriptions(auth, widget.channel.channelID); + + //await Future.delayed(const Duration(seconds: 10), () {}); + + return subs; + } finally { + _incLoadingIndeterminateCounter(-1); + } + } + + Future _getOwner(AppAuth auth) 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, widget.channel.ownerUserID); + + //await Future.delayed(const Duration(seconds: 10), () {}); + + return owner; + } finally { + _incLoadingIndeterminateCounter(-1); + } + } + + List<(IconData, void Function())> _getForignSubActions(Subscription sub) { + if (sub.confirmed) { + return [(FontAwesomeIcons.solidSquareXmark, () => _cancelForeignSubscription(sub))]; + } else { + return [ + (FontAwesomeIcons.solidSquareCheck, () => _confirmForeignSubscription(sub)), + (FontAwesomeIcons.solidSquareXmark, () => _denyForeignSubscription(sub)), + ]; + } + } + + void _incLoadingIndeterminateCounter(int delta) { + setState(() { + _loadingIndeterminateCounter += delta; + AppBarState().setLoadingIndeterminate(_loadingIndeterminateCounter > 0); + }); + } } diff --git a/flutter/lib/pages/message_view/message_view.dart b/flutter/lib/pages/message_view/message_view.dart index d3446e9..2877051 100644 --- a/flutter/lib/pages/message_view/message_view.dart +++ b/flutter/lib/pages/message_view/message_view.dart @@ -131,59 +131,54 @@ class _MessageViewPageState extends State { SizedBox(height: 8), if (message.content != null) ..._buildMessageContent(context, message), SizedBox(height: 8), - if (message.senderName != null) _buildMetaCard(context, FontAwesomeIcons.solidSignature, 'Sender', [message.senderName!], () => {/*TODO*/}), - _buildMetaCard( - context, - FontAwesomeIcons.solidGearCode, - 'KeyToken', - [ - message.usedKeyID, - token?.name ?? '...', - ], - () => {/*TODO*/}), - _buildMetaCard( - context, - FontAwesomeIcons.solidIdCardClip, - 'MessageID', - [ - message.messageID, - message.userMessageID ?? '', - ], - null), - _buildMetaCard( - context, - FontAwesomeIcons.solidSnake, - 'Channel', - [ - message.channelID, - channel?.displayName ?? message.channelInternalName, - ], - () => {/*TODO*/}), - _buildMetaCard( - context, - FontAwesomeIcons.solidTimer, - 'Timestamp', - [ - message.timestamp, - ], - null), - _buildMetaCard( - context, - FontAwesomeIcons.solidUser, - 'User', - [ - user?.userID ?? '...', - user?.username ?? '', - ], - () => {/*TODO*/}), //TODO - _buildMetaCard( - context, - FontAwesomeIcons.solidBolt, - 'Priority', - [ - _prettyPrintPriority(message.priority), - ], - () => {/*TODO*/}), //TODO + if (message.senderName != null) + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidSignature, + title: 'Sender', + values: [message.senderName!], + mainAction: () => {/*TODO*/}, + ), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidGearCode, + title: 'KeyToken', + values: [message.usedKeyID, token?.name ?? '...'], + mainAction: () => {/*TODO*/}, + ), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidIdCardClip, + title: 'MessageID', + values: [message.messageID, message.userMessageID ?? ''], + ), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidSnake, + title: 'Channel', + values: [message.channelID, channel?.displayName ?? message.channelInternalName], + mainAction: () => {/*TODO*/}, + ), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidTimer, + title: 'Timestamp', + values: [message.timestamp], + ), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidUser, + title: 'User', + values: [user?.userID ?? '...', user?.username ?? ''], + mainAction: () => {/*TODO*/}, + ), + UI.metaCard( + context: context, + icon: FontAwesomeIcons.solidBolt, + title: 'Priority', + values: [_prettyPrintPriority(message.priority)], + mainAction: () => {/*TODO*/}, + ), if (message.senderUserID == userAccUserID) UI.button(text: "Delete Message", onPressed: () {/*TODO*/}, color: Colors.red[900]), ], ), @@ -260,42 +255,6 @@ class _MessageViewPageState extends State { ]; } - Widget _buildMetaCard(BuildContext context, IconData icn, String title, List values, void Function()? action) { - final container = UI.box( - context: context, - padding: EdgeInsets.fromLTRB(16, 2, 4, 2), - child: Row( - children: [ - FaIcon(icn, size: 18), - SizedBox(width: 16), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), - for (final val in values) Text(val, style: const TextStyle(fontSize: 14)), - ], - ), - ], - ), - ); - - if (action == null) { - return Padding( - padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0), - child: container, - ); - } else { - return Padding( - padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0), - child: InkWell( - splashColor: Theme.of(context).splashColor, - onTap: action, - child: container, - ), - ); - } - } - String _preformatTitle(SCNMessage message) { return message.title.replaceAll('\n', '').replaceAll('\r', '').replaceAll('\t', ' '); } diff --git a/flutter/lib/state/app_auth.dart b/flutter/lib/state/app_auth.dart index 7524f94..038feed 100644 --- a/flutter/lib/state/app_auth.dart +++ b/flutter/lib/state/app_auth.dart @@ -182,6 +182,10 @@ class AppAuth extends ChangeNotifier implements TokenSource { return user; } + User? getUserOrNull() { + return _user?.$1; + } + Future loadClient({bool force = false, Duration? forceIfOlder = null}) async { if (forceIfOlder != null && _client != null && _client!.$2.difference(DateTime.now()) > forceIfOlder) { force = true; @@ -212,6 +216,10 @@ class AppAuth extends ChangeNotifier implements TokenSource { } } + Client? getClientOrNull() { + return _client?.$1; + } + @override String getToken() { return _tokenAdmin!; diff --git a/flutter/lib/utils/ui.dart b/flutter/lib/utils/ui.dart index e7d57cd..4a81ad7 100644 --- a/flutter/lib/utils/ui.dart +++ b/flutter/lib/utils/ui.dart @@ -106,4 +106,49 @@ class UI { child: child, ); } + + static Widget metaCard({required BuildContext context, required IconData icon, required String title, required List values, void Function()? mainAction, List<(IconData, void Function())>? iconActions}) { + final container = UI.box( + context: context, + padding: EdgeInsets.fromLTRB(16, 2, 4, 2), + child: Row( + children: [ + FaIcon(icon, size: 18), + SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + for (final val in values) Text(val, style: const TextStyle(fontSize: 14)), + ], + ), + ), + if (iconActions != null) ...[ + SizedBox(width: 12), + for (final iconAction in iconActions) ...[ + SizedBox(width: 4), + IconButton(icon: FaIcon(iconAction.$1), onPressed: iconAction.$2), + ], + ], + ], + ), + ); + + if (mainAction == null) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0), + child: container, + ); + } else { + return Padding( + padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0), + child: InkWell( + splashColor: Theme.of(context).splashColor, + onTap: mainAction, + child: container, + ), + ); + } + } }