channel_view page

This commit is contained in:
Mike Schwörer 2024-06-25 20:49:40 +02:00
parent e2dbe8866d
commit 2b23404461
Signed by: Mikescher
GPG Key ID: D3C7172E0A70F8CF
7 changed files with 367 additions and 177 deletions

View File

@ -247,6 +247,16 @@ class APIClient {
);
}
static Future<List<Subscription>> 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<dynamic>),
authToken: auth.getToken(),
);
}
static Future<List<Client>> getClientList(TokenSource auth) async {
return await _request(
name: 'getClientList',

View File

@ -86,6 +86,10 @@ class User {
'max_user_message_id_length': maxUserMessageIDLength,
};
}
UserPreview toPreview() {
return UserPreview(userID: userID, username: username);
}
}
class UserWithClientsAndKeys {

View File

@ -110,8 +110,10 @@ class _ChannelListItemState extends State<ChannelListItem> {
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
}

View File

@ -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<ChannelViewPage> {
static final _dateFormat = DateFormat('yyyy-MM-dd kk:mm');
late ImmediateFuture<String?> _futureSubscribeKey;
late ImmediateFuture<List<Subscription>> _futureSubscriptions;
late ImmediateFuture<UserPreview> _futureOwner;
int _loadingIndeterminateCounter = 0;
@override
void initState() {
final userAcc = Provider.of<AppAuth>(context, listen: false);
if (widget.channel.ownerUserID == userAcc.userID) {
if (widget.channel.subscribeKey != null) {
_futureSubscribeKey = ImmediateFuture<String?>.ofValue(widget.channel.subscribeKey);
} else {
_futureSubscribeKey = ImmediateFuture<String?>.ofFuture(_getSubScribeKey(userAcc));
}
_futureSubscriptions = ImmediateFuture<List<Subscription>>.ofFuture(_listSubscriptions(userAcc));
} else {
_futureSubscribeKey = ImmediateFuture<String?>.ofValue(null);
_futureSubscriptions = ImmediateFuture<List<Subscription>>.ofValue([]);
}
if (widget.channel.ownerUserID == userAcc.userID) {
var cacheUser = userAcc.getUserOrNull();
if (cacheUser != null) {
_futureOwner = ImmediateFuture<UserPreview>.ofValue(cacheUser.toPreview());
} else {
_futureOwner = ImmediateFuture<UserPreview>.ofFuture(_getOwner(userAcc));
}
} else {
_futureOwner = ImmediateFuture<UserPreview>.ofFuture(APIClient.getUserPreview(userAcc, widget.channel.ownerUserID));
}
super.initState();
}
@ -57,109 +82,246 @@ class _ChannelViewPageState extends State<ChannelViewPage> {
Widget _buildChannelView(BuildContext context) {
final userAccUserID = context.select<AppAuth, String?>((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<Widget> _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<String> 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<String?> _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<List<Subscription>> _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<UserPreview> _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);
});
}
}

View File

@ -131,59 +131,54 @@ class _MessageViewPageState extends State<MessageViewPage> {
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<MessageViewPage> {
];
}
Widget _buildMetaCard(BuildContext context, IconData icn, String title, List<String> 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', ' ');
}

View File

@ -182,6 +182,10 @@ class AppAuth extends ChangeNotifier implements TokenSource {
return user;
}
User? getUserOrNull() {
return _user?.$1;
}
Future<Client?> 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!;

View File

@ -106,4 +106,49 @@ class UI {
child: child,
);
}
static Widget metaCard({required BuildContext context, required IconData icon, required String title, required List<String> 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,
),
);
}
}
}