diff --git a/lib/api/marianumcloud/talk/getParticipants/getParticipants.dart b/lib/api/marianumcloud/talk/getParticipants/getParticipants.dart new file mode 100644 index 0000000..2119d92 --- /dev/null +++ b/lib/api/marianumcloud/talk/getParticipants/getParticipants.dart @@ -0,0 +1,22 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../talkApi.dart'; +import 'getParticipantsResponse.dart'; + +class GetParticipants extends TalkApi { + String token; + GetParticipants(this.token) : super("v4/room/$token/participants", null); + + @override + GetParticipantsResponse assemble(String raw) { + return GetParticipantsResponse.fromJson(jsonDecode(raw)['ocs']); + } + + @override + Future request(Uri uri, Object? body, Map? headers) { + return http.get(uri, headers: headers); + } + +} \ No newline at end of file diff --git a/lib/api/marianumcloud/talk/getParticipants/getParticipantsCache.dart b/lib/api/marianumcloud/talk/getParticipants/getParticipantsCache.dart new file mode 100644 index 0000000..9ca1c70 --- /dev/null +++ b/lib/api/marianumcloud/talk/getParticipants/getParticipantsCache.dart @@ -0,0 +1,26 @@ +import 'dart:convert'; + +import '../../../requestCache.dart'; +import 'getParticipants.dart'; +import 'getParticipantsResponse.dart'; + +class GetParticipantsCache extends RequestCache { + String chatToken; + + GetParticipantsCache({required onUpdate, required this.chatToken}) : super(RequestCache.cacheNothing, onUpdate) { + start("MarianumMobile", "nc-chat-participants-$chatToken"); + } + + @override + Future onLoad() { + return GetParticipants( + chatToken, + ).run(); + } + + @override + GetParticipantsResponse onLocalData(String json) { + return GetParticipantsResponse.fromJson(jsonDecode(json)); + } + +} \ No newline at end of file diff --git a/lib/api/marianumcloud/talk/getParticipants/getParticipantsResponse.dart b/lib/api/marianumcloud/talk/getParticipants/getParticipantsResponse.dart new file mode 100644 index 0000000..2d7682c --- /dev/null +++ b/lib/api/marianumcloud/talk/getParticipants/getParticipantsResponse.dart @@ -0,0 +1,70 @@ + +import 'package:json_annotation/json_annotation.dart'; + +part 'getParticipantsResponse.g.dart'; + +@JsonSerializable(explicitToJson: true) +class GetParticipantsResponse { + Set data; + + GetParticipantsResponse(this.data); + + factory GetParticipantsResponse.fromJson(Map json) => _$GetParticipantsResponseFromJson(json); + Map toJson() => _$GetParticipantsResponseToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class GetParticipantsResponseObject { + int attendeeId; + String actorType; + String actorId; + String displayName; + GetParticipantsResponseObjectParticipantType participantType; + int lastPing; + GetParticipantsResponseObjectParticipantsInCallFlags inCall; + int permissions; + int attendeePermissions; + String? sessionId; + List sessionIds; + String? status; + String? statusIcon; + String? statusMessage; + String? roomToken; + + GetParticipantsResponseObject( + this.attendeeId, + this.actorType, + this.actorId, + this.displayName, + this.participantType, + this.lastPing, + this.inCall, + this.permissions, + this.attendeePermissions, + this.sessionId, + this.sessionIds, + this.status, + this.statusIcon, + this.statusMessage, + this.roomToken); + + factory GetParticipantsResponseObject.fromJson(Map json) => _$GetParticipantsResponseObjectFromJson(json); + Map toJson() => _$GetParticipantsResponseObjectToJson(this); +} + +enum GetParticipantsResponseObjectParticipantType { + @JsonValue(1) owner, + @JsonValue(2) moderator, + @JsonValue(3) user, + @JsonValue(4) guest, + @JsonValue(5) userFollowingPublicLink, + @JsonValue(6) guestWithModeratorPermissions +} + +enum GetParticipantsResponseObjectParticipantsInCallFlags { + @JsonValue(0) disconnected, + @JsonValue(1) inCall, + @JsonValue(2) providesAudio, + @JsonValue(3) providesVideo, + @JsonValue(4) usesSipDialIn +} \ No newline at end of file diff --git a/lib/api/marianumcloud/talk/getParticipants/getParticipantsResponse.g.dart b/lib/api/marianumcloud/talk/getParticipants/getParticipantsResponse.g.dart new file mode 100644 index 0000000..6dafc62 --- /dev/null +++ b/lib/api/marianumcloud/talk/getParticipants/getParticipantsResponse.g.dart @@ -0,0 +1,83 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'getParticipantsResponse.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GetParticipantsResponse _$GetParticipantsResponseFromJson( + Map json) => + GetParticipantsResponse( + (json['data'] as List) + .map((e) => + GetParticipantsResponseObject.fromJson(e as Map)) + .toSet(), + ); + +Map _$GetParticipantsResponseToJson( + GetParticipantsResponse instance) => + { + 'data': instance.data.map((e) => e.toJson()).toList(), + }; + +GetParticipantsResponseObject _$GetParticipantsResponseObjectFromJson( + Map json) => + GetParticipantsResponseObject( + json['attendeeId'] as int, + json['actorType'] as String, + json['actorId'] as String, + json['displayName'] as String, + $enumDecode(_$GetParticipantsResponseObjectParticipantTypeEnumMap, + json['participantType']), + json['lastPing'] as int, + $enumDecode(_$GetParticipantsResponseObjectParticipantsInCallFlagsEnumMap, + json['inCall']), + json['permissions'] as int, + json['attendeePermissions'] as int, + json['sessionId'] as String?, + (json['sessionIds'] as List).map((e) => e as String).toList(), + json['status'] as String?, + json['statusIcon'] as String?, + json['statusMessage'] as String?, + json['roomToken'] as String?, + ); + +Map _$GetParticipantsResponseObjectToJson( + GetParticipantsResponseObject instance) => + { + 'attendeeId': instance.attendeeId, + 'actorType': instance.actorType, + 'actorId': instance.actorId, + 'displayName': instance.displayName, + 'participantType': _$GetParticipantsResponseObjectParticipantTypeEnumMap[ + instance.participantType]!, + 'lastPing': instance.lastPing, + 'inCall': _$GetParticipantsResponseObjectParticipantsInCallFlagsEnumMap[ + instance.inCall]!, + 'permissions': instance.permissions, + 'attendeePermissions': instance.attendeePermissions, + 'sessionId': instance.sessionId, + 'sessionIds': instance.sessionIds, + 'status': instance.status, + 'statusIcon': instance.statusIcon, + 'statusMessage': instance.statusMessage, + 'roomToken': instance.roomToken, + }; + +const _$GetParticipantsResponseObjectParticipantTypeEnumMap = { + GetParticipantsResponseObjectParticipantType.owner: 1, + GetParticipantsResponseObjectParticipantType.moderator: 2, + GetParticipantsResponseObjectParticipantType.user: 3, + GetParticipantsResponseObjectParticipantType.guest: 4, + GetParticipantsResponseObjectParticipantType.userFollowingPublicLink: 5, + GetParticipantsResponseObjectParticipantType.guestWithModeratorPermissions: 6, +}; + +const _$GetParticipantsResponseObjectParticipantsInCallFlagsEnumMap = { + GetParticipantsResponseObjectParticipantsInCallFlags.disconnected: 0, + GetParticipantsResponseObjectParticipantsInCallFlags.inCall: 1, + GetParticipantsResponseObjectParticipantsInCallFlags.providesAudio: 2, + GetParticipantsResponseObjectParticipantsInCallFlags.providesVideo: 3, + GetParticipantsResponseObjectParticipantsInCallFlags.usesSipDialIn: 4, +}; diff --git a/lib/api/marianumcloud/talk/talkApi.dart b/lib/api/marianumcloud/talk/talkApi.dart index 0f3b254..cc0ef16 100644 --- a/lib/api/marianumcloud/talk/talkApi.dart +++ b/lib/api/marianumcloud/talk/talkApi.dart @@ -55,9 +55,9 @@ abstract class TalkApi extends ApiRequest { try { assembled = assemble(data.body); return assembled; - } catch (_) { + } catch (e) { // TODO report error - log("Error assembling Talk API ${T.toString()} response on ${endpoint.path} with body: $body and headers: ${headers.toString()}"); + log("Error assembling Talk API ${T.toString()} message: ${e.toString()} response on ${endpoint.path} with request body: $body and request headers: ${headers.toString()}"); } throw Exception("Error assembling Talk API response"); diff --git a/lib/view/pages/talk/chatDetails/chatInfo.dart b/lib/view/pages/talk/chatDetails/chatInfo.dart new file mode 100644 index 0000000..7b0d542 --- /dev/null +++ b/lib/view/pages/talk/chatDetails/chatInfo.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; + +import '../../../../api/marianumcloud/talk/getParticipants/getParticipantsCache.dart'; +import '../../../../api/marianumcloud/talk/getParticipants/getParticipantsResponse.dart'; +import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; +import '../../../../widget/largeProfilePictureView.dart'; +import '../../../../widget/loadingSpinner.dart'; +import '../../../../widget/userAvatar.dart'; +import '../talkNavigator.dart'; +import 'participants/participantsListView.dart'; + +class ChatInfo extends StatefulWidget { + final GetRoomResponseObject room; + const ChatInfo(this.room, {super.key}); + + @override + State createState() => _ChatInfoState(); +} + +class _ChatInfoState extends State { + GetParticipantsResponse? participants; + + @override + void initState() { + GetParticipantsCache( + chatToken: widget.room.token, + onUpdate: (GetParticipantsResponse data) { + setState(() { + participants = data; + }); + } + ); + super.initState(); + } + + @override + Widget build(BuildContext context) { + bool isGroup = widget.room.type != GetRoomResponseObjectConversationType.oneToOne; + return Scaffold( + appBar: AppBar( + title: Text(widget.room.displayName), + ), + body: Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 30), + GestureDetector( + child: UserAvatar( + username: widget.room.name, + isGroup: isGroup, + size: 80, + ), + onTap: () { + if(isGroup) return; + TalkNavigator.pushSplitView(context, LargeProfilePictureView(widget.room.name)); + }, + ), + const SizedBox(height: 30), + Text(widget.room.displayName, textAlign: TextAlign.center, style: const TextStyle(fontSize: 30)), + if(!isGroup) Text(widget.room.name), + const SizedBox(height: 10), + if(isGroup) Text(widget.room.description, textAlign: TextAlign.center), + const SizedBox(height: 30), + if(participants == null) const LoadingSpinner(), + if(participants != null) ...[ + ListTile( + leading: const Icon(Icons.supervised_user_circle), + title: Text("${participants!.data.length} Teilnehmer"), + trailing: const Icon(Icons.arrow_right), + onTap: () => TalkNavigator.pushSplitView(context, ParticipantsListView(participants!)), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/view/pages/talk/chatDetails/participants/participantsListView.dart b/lib/view/pages/talk/chatDetails/participants/participantsListView.dart new file mode 100644 index 0000000..82e5b06 --- /dev/null +++ b/lib/view/pages/talk/chatDetails/participants/participantsListView.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +import '../../../../../api/marianumcloud/talk/getParticipants/getParticipantsResponse.dart'; +import '../../../../../widget/userAvatar.dart'; + +class ParticipantsListView extends StatefulWidget { + final GetParticipantsResponse participantsResponse; + const ParticipantsListView(this.participantsResponse, {super.key}); + + @override + State createState() => _ParticipantsListViewState(); +} + +class _ParticipantsListViewState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("Teilnehmende"), + ), + body: ListView( + children: widget.participantsResponse.data.map((participant) { + return ListTile( + leading: UserAvatar(username: participant.actorId), + title: Text(participant.displayName), + subtitle: participant.statusMessage != null ? Text(participant.statusMessage!) : null, + ); + }).toList(), + ), + ); + } +} diff --git a/lib/view/pages/talk/chatView.dart b/lib/view/pages/talk/chatView.dart index 1c2fe91..29e696b 100644 --- a/lib/view/pages/talk/chatView.dart +++ b/lib/view/pages/talk/chatView.dart @@ -7,10 +7,13 @@ import '../../../api/marianumcloud/talk/chat/getChatResponse.dart'; import '../../../api/marianumcloud/talk/room/getRoomResponse.dart'; import '../../../theming/appTheme.dart'; import '../../../model/chatList/chatProps.dart'; +import '../../../widget/clickableAppBar.dart'; import '../../../widget/loadingSpinner.dart'; import '../../../widget/userAvatar.dart'; +import 'chatDetails/chatInfo.dart'; import 'components/chatBubble.dart'; import 'components/chatTextfield.dart'; +import 'talkNavigator.dart'; class ChatView extends StatefulWidget { final GetRoomResponseObject room; @@ -74,15 +77,20 @@ class _ChatViewState extends State { return Scaffold( backgroundColor: const Color(0xffefeae2), - appBar: AppBar( - title: Row( - children: [ - widget.avatar, - const SizedBox(width: 10), - Expanded( - child: Text(widget.room.displayName, overflow: TextOverflow.ellipsis, maxLines: 1), - ) - ], + appBar: ClickableAppBar( + onTap: () { + TalkNavigator.pushSplitView(context, ChatInfo(widget.room)); + }, + appBar: AppBar( + title: Row( + children: [ + widget.avatar, + const SizedBox(width: 10), + Expanded( + child: Text(widget.room.displayName, overflow: TextOverflow.ellipsis, maxLines: 1), + ) + ], + ), ), ), body: Container( diff --git a/lib/view/pages/talk/components/chatTile.dart b/lib/view/pages/talk/components/chatTile.dart index 8710b62..ca60972 100644 --- a/lib/view/pages/talk/components/chatTile.dart +++ b/lib/view/pages/talk/components/chatTile.dart @@ -2,8 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_split_view/flutter_split_view.dart'; import 'package:jiffy/jiffy.dart'; -import 'package:marianum_mobile/view/pages/talk/talkNavigator.dart'; -import 'package:persistent_bottom_nav_bar/persistent_tab_view.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -18,6 +16,7 @@ import '../../../../widget/confirmDialog.dart'; import '../../../../widget/debug/debugTile.dart'; import '../../../../widget/userAvatar.dart'; import '../chatView.dart'; +import '../talkNavigator.dart'; class ChatTile extends StatefulWidget { final GetRoomResponseObject data; @@ -120,7 +119,7 @@ class _ChatTileState extends State { onTap: () async { setCurrentAsRead(); ChatView view = ChatView(room: widget.data, selfId: username, avatar: circleAvatar); - TalkNavigator.pushSplitView(context, view); + TalkNavigator.pushSplitView(context, view, overrideToSingleSubScreen: true); Provider.of(context, listen: false).setQueryToken(widget.data.token); }, onLongPress: () { diff --git a/lib/view/pages/talk/talkNavigator.dart b/lib/view/pages/talk/talkNavigator.dart index 8c773d9..96b09de 100644 --- a/lib/view/pages/talk/talkNavigator.dart +++ b/lib/view/pages/talk/talkNavigator.dart @@ -4,9 +4,10 @@ import 'package:flutter_split_view/flutter_split_view.dart'; import 'package:persistent_bottom_nav_bar/persistent_tab_view.dart'; class TalkNavigator { - static void pushSplitView(BuildContext context, Widget view) { - if(SplitView.of(context).isSecondaryVisible) { - SplitView.of(context).setSecondary(view); + static void pushSplitView(BuildContext context, Widget view, {bool overrideToSingleSubScreen = false}) { + if(context.findAncestorStateOfType() != null && SplitView.of(context).isSecondaryVisible) { + SplitViewState splitView = SplitView.of(context); + overrideToSingleSubScreen ? splitView.setSecondary(view) : splitView.push(view); } else { PersistentNavBarNavigator.pushNewScreen(context, screen: view, withNavBar: false); } diff --git a/lib/widget/clickableAppBar.dart b/lib/widget/clickableAppBar.dart new file mode 100644 index 0000000..b83cb10 --- /dev/null +++ b/lib/widget/clickableAppBar.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +class ClickableAppBar extends StatelessWidget implements PreferredSizeWidget { + final VoidCallback onTap; + final AppBar appBar; + const ClickableAppBar({required this.onTap, required this.appBar, super.key}); + + @override + Widget build(BuildContext context) { + return GestureDetector(onTap: onTap, child: appBar); + } + + @override + Size get preferredSize => appBar.preferredSize; +} diff --git a/lib/widget/largeProfilePictureView.dart b/lib/widget/largeProfilePictureView.dart new file mode 100644 index 0000000..7ea7966 --- /dev/null +++ b/lib/widget/largeProfilePictureView.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:photo_view/photo_view.dart'; + +import '../model/endpointData.dart'; + +class LargeProfilePictureView extends StatelessWidget { + final String username; + const LargeProfilePictureView(this.username, {super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("Profilbild"), + ), + body: PhotoView( + imageProvider: Image.network("https://${EndpointData().nextcloud().full()}/avatar/$username/1024").image, + backgroundDecoration: BoxDecoration(color: Theme.of(context).colorScheme.surface), + ), + ); + } +} diff --git a/lib/widget/userAvatar.dart b/lib/widget/userAvatar.dart index 1afdb55..e564733 100644 --- a/lib/widget/userAvatar.dart +++ b/lib/widget/userAvatar.dart @@ -5,16 +5,19 @@ import '../model/endpointData.dart'; class UserAvatar extends StatelessWidget { final String username; final bool isGroup; - const UserAvatar({required this.username, this.isGroup = false, super.key}); + final int size; + const UserAvatar({required this.username, this.isGroup = false, this.size = 20, super.key}); @override Widget build(BuildContext context) { return CircleAvatar( - foregroundImage: !isGroup ? Image.network("https://${EndpointData().nextcloud().full()}/avatar/$username/128").image : null, + foregroundImage: !isGroup ? Image.network("https://${EndpointData().nextcloud().full()}/avatar/$username/$size").image : null, backgroundColor: Theme.of(context).primaryColor, foregroundColor: Colors.white, onForegroundImageError: !isGroup ? (o, t) {} : null, - child: isGroup ? const Icon(Icons.group) : const Icon(Icons.person), + radius: size.toDouble(), + child: isGroup ? Icon(Icons.group, size: size.toDouble()) : Icon(Icons.person, size: size.toDouble()), ); + } }