simplified code across the app and consolidated duplicated helpers
This commit is contained in:
@@ -61,15 +61,15 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
|
||||
// Captured in initState because the framework has unmounted us by the
|
||||
// time dispose runs.
|
||||
ChatBloc? _chatBlocRef;
|
||||
ChatListBloc? _chatListBlocRef;
|
||||
late final ChatBloc _chatBloc;
|
||||
late final ChatListBloc _chatListBloc;
|
||||
PageRoute<dynamic>? _subscribedRoute;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_chatBlocRef = context.read<ChatBloc>();
|
||||
_chatListBlocRef = context.read<ChatListBloc>();
|
||||
_chatBloc = context.read<ChatBloc>();
|
||||
_chatListBloc = context.read<ChatListBloc>();
|
||||
_positionsListener.itemPositions.addListener(_onScrollPositions);
|
||||
NotificationTasks.clearNotificationsForChat(widget.room.token);
|
||||
}
|
||||
@@ -83,9 +83,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
// before the user reaches the top — the load stays invisible.
|
||||
final maxIndex = positions.map((p) => p.index).reduce(math.max);
|
||||
if (maxIndex < _itemCount - _kLoadOlderPrefetchRows) return;
|
||||
final data = _chatBlocRef?.state.data;
|
||||
if (data == null || !data.hasMoreOld || data.isLoadingOlder) return;
|
||||
_chatBlocRef?.loadOlder();
|
||||
_chatBloc.loadOlder();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -107,7 +105,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
// A stacked chat above us was just popped (typical: notification tap
|
||||
// opened another chat). The global ChatBloc currently points at that
|
||||
// other chat's token, so our isReady predicate fails until we re-claim.
|
||||
_chatBlocRef?.setToken(widget.room.token);
|
||||
_chatBloc.setToken(widget.room.token);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -117,7 +115,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
}
|
||||
_positionsListener.itemPositions.removeListener(_onScrollPositions);
|
||||
_markAsReadFinal();
|
||||
_chatBlocRef?.leaveChat(widget.room.token);
|
||||
_chatBloc.leaveChat(widget.room.token);
|
||||
_searchTextController.dispose();
|
||||
Debouncer.cancel(_searchDebounceTag);
|
||||
super.dispose();
|
||||
@@ -129,15 +127,15 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
/// belongs to a different room, and writing its max-id as our marker
|
||||
/// would regress our server cursor.
|
||||
void _markAsReadFinal() {
|
||||
final state = _chatBlocRef?.state.data;
|
||||
final state = _chatBloc.state.data;
|
||||
if (state == null) return;
|
||||
if (state.currentToken != widget.room.token) return;
|
||||
final response = state.chatResponse;
|
||||
if (response == null) return;
|
||||
final maxId = response.data.map((m) => m.id).fold<int>(0, math.max);
|
||||
if (maxId == 0) return;
|
||||
_chatListBlocRef?.markRoomAsRead(widget.room.token, maxId);
|
||||
unawaited(_chatBlocRef!.sendServerReadMarker(widget.room.token, maxId));
|
||||
_chatListBloc.markRoomAsRead(widget.room.token, maxId);
|
||||
unawaited(_chatBloc.sendServerReadMarker(widget.room.token, maxId));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -148,31 +146,34 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
}
|
||||
}
|
||||
|
||||
void _refresh() {
|
||||
context.read<ChatBloc>().setToken(widget.room.token);
|
||||
}
|
||||
|
||||
void _refetch({bool renew = false}) => _refresh();
|
||||
void _refetch() => _chatBloc.setToken(widget.room.token);
|
||||
|
||||
// The built rows only depend on the chat data and the search state; the list
|
||||
// itself rebuilds far more often (loading flips, keyboard, parent rebuilds),
|
||||
// and re-sorting plus re-creating every bubble each time is wasted work.
|
||||
// The top marker is cached separately so a loading-older flip doesn't
|
||||
// rebuild every message row.
|
||||
Object? _messagesKey;
|
||||
List<Widget> _messages = const [];
|
||||
Object? _itemsKey;
|
||||
List<Widget> _items = const [];
|
||||
|
||||
List<Widget> _itemsFor(ChatState state) {
|
||||
final key = (
|
||||
final messagesKey = (
|
||||
state.chatResponse,
|
||||
state.isLoadingOlder,
|
||||
state.hasMoreOld,
|
||||
_searchActive,
|
||||
_searchQuery,
|
||||
_activeMatchIndex,
|
||||
widget.room,
|
||||
);
|
||||
if (messagesKey != _messagesKey) {
|
||||
_messagesKey = messagesKey;
|
||||
_messages = _buildMessages(state.chatResponse!).reversed.toList();
|
||||
}
|
||||
final key = (_messages, state.isLoadingOlder, state.hasMoreOld);
|
||||
if (key == _itemsKey) return _items;
|
||||
_itemsKey = key;
|
||||
final items = _buildMessages(state.chatResponse!).reversed.toList();
|
||||
final items = [..._messages];
|
||||
// reverse:true renders index 0 at the bottom, so the top marker
|
||||
// (spinner / start-of-chat) goes at the end.
|
||||
if (state.isLoadingOlder) {
|
||||
@@ -191,26 +192,20 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
return _items = items;
|
||||
}
|
||||
|
||||
void _enterSearchMode() {
|
||||
setState(() {
|
||||
_searchActive = true;
|
||||
_searchQuery = '';
|
||||
_matches = const [];
|
||||
_activeMatchIndex = 0;
|
||||
_matchesComputedFor = null;
|
||||
_matchesComputedQuery = null;
|
||||
_searchTextController.clear();
|
||||
});
|
||||
}
|
||||
void _enterSearchMode() => _resetSearch(active: true);
|
||||
|
||||
void _exitSearchMode() {
|
||||
Debouncer.cancel(_searchDebounceTag);
|
||||
_matchIndices.clear();
|
||||
_resetSearch(active: false);
|
||||
}
|
||||
|
||||
void _resetSearch({required bool active}) {
|
||||
setState(() {
|
||||
_searchActive = false;
|
||||
_searchActive = active;
|
||||
_searchQuery = '';
|
||||
_matches = const [];
|
||||
_activeMatchIndex = 0;
|
||||
_matchIndices.clear();
|
||||
_matchesComputedFor = null;
|
||||
_matchesComputedQuery = null;
|
||||
_searchTextController.clear();
|
||||
@@ -233,7 +228,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
}
|
||||
|
||||
void _applySearch(String q) {
|
||||
final chatResponse = context.read<ChatBloc>().state.data?.chatResponse;
|
||||
final chatResponse = _chatBloc.state.data?.chatResponse;
|
||||
setState(() {
|
||||
_searchQuery = q;
|
||||
_activeMatchIndex = 0;
|
||||
@@ -260,19 +255,12 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
_matchesComputedQuery = _searchQuery;
|
||||
}
|
||||
|
||||
void _goToPreviousMatch() {
|
||||
if (_matches.isEmpty) return;
|
||||
setState(() {
|
||||
_activeMatchIndex = (_activeMatchIndex + 1) % _matches.length;
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToActiveMatch());
|
||||
}
|
||||
|
||||
void _goToNextMatch() {
|
||||
// Matches are newest-first, so "previous" (older) steps forward.
|
||||
void _stepMatch(int delta) {
|
||||
if (_matches.isEmpty) return;
|
||||
setState(() {
|
||||
_activeMatchIndex =
|
||||
(_activeMatchIndex - 1 + _matches.length) % _matches.length;
|
||||
(_activeMatchIndex + delta + _matches.length) % _matches.length;
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToActiveMatch());
|
||||
}
|
||||
@@ -379,6 +367,11 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final input = ChatTextfield(
|
||||
widget.room.token,
|
||||
selfId: widget.selfId,
|
||||
roomType: widget.room.type,
|
||||
);
|
||||
return _KeyboardDismissPopScope(
|
||||
child: Scaffold(
|
||||
backgroundColor: const Color(0xffefeae2),
|
||||
@@ -389,8 +382,8 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
activeIndex: _matches.isEmpty ? -1 : _activeMatchIndex,
|
||||
onChanged: _onSearchChanged,
|
||||
onClose: _exitSearchMode,
|
||||
onPrevious: _matches.isEmpty ? null : _goToPreviousMatch,
|
||||
onNext: _matches.isEmpty ? null : _goToNextMatch,
|
||||
onPrevious: _matches.isEmpty ? null : () => _stepMatch(1),
|
||||
onNext: _matches.isEmpty ? null : () => _stepMatch(-1),
|
||||
)
|
||||
: ClickableAppBar(
|
||||
onTap: () =>
|
||||
@@ -443,18 +436,8 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
ColoredBox(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: TalkNavigator.isSecondaryVisible(context)
|
||||
? ChatTextfield(
|
||||
widget.room.token,
|
||||
selfId: widget.selfId,
|
||||
roomType: widget.room.type,
|
||||
)
|
||||
: SafeArea(
|
||||
child: ChatTextfield(
|
||||
widget.room.token,
|
||||
selfId: widget.selfId,
|
||||
roomType: widget.room.type,
|
||||
),
|
||||
),
|
||||
? input
|
||||
: SafeArea(child: input),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -20,7 +20,7 @@ class ChatBubbleStyles {
|
||||
? const Color(0xff182229)
|
||||
: Colors.white,
|
||||
elevation: 2,
|
||||
margin: const BubbleEdges.only(bottom: 20, top: 10),
|
||||
margin: const EdgeInsets.only(bottom: 20, top: 10),
|
||||
alignment: Alignment.center,
|
||||
);
|
||||
|
||||
@@ -30,7 +30,7 @@ class ChatBubbleStyles {
|
||||
? const Color(0xff202c33)
|
||||
: Colors.white,
|
||||
elevation: 1,
|
||||
margin: const BubbleEdges.only(bottom: 10, left: 10, right: 50),
|
||||
margin: const EdgeInsets.only(bottom: 10, left: 10, right: 50),
|
||||
alignment: Alignment.topLeft,
|
||||
);
|
||||
|
||||
@@ -40,7 +40,7 @@ class ChatBubbleStyles {
|
||||
? const Color(0xff005c4b)
|
||||
: const Color(0xffd3d3d3),
|
||||
elevation: 1,
|
||||
margin: const BubbleEdges.only(bottom: 10, right: 10, left: 50),
|
||||
margin: const EdgeInsets.only(bottom: 10, right: 10, left: 50),
|
||||
alignment: Alignment.topRight,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,27 +11,33 @@ import '../widgets/highlighted_linkify.dart';
|
||||
import '../widgets/message_markdown.dart';
|
||||
|
||||
class ChatMessage {
|
||||
String originalMessage;
|
||||
Map<String, RichObjectString>? originalData;
|
||||
final String originalMessage;
|
||||
final Map<String, RichObjectString>? originalData;
|
||||
|
||||
RichObjectString? file;
|
||||
String content = '';
|
||||
final RichObjectString? file;
|
||||
final String content;
|
||||
|
||||
ChatMessage({required this.originalMessage, this.originalData}) {
|
||||
if (originalData?.containsKey('file') ?? false) {
|
||||
file = originalData?['file'];
|
||||
}
|
||||
content = RichObjectStringProcessor.parseToString(
|
||||
originalMessage,
|
||||
originalData,
|
||||
);
|
||||
}
|
||||
ChatMessage({required this.originalMessage, this.originalData})
|
||||
: file = originalData?['file'],
|
||||
content = RichObjectStringProcessor.parseToString(
|
||||
originalMessage,
|
||||
originalData,
|
||||
);
|
||||
|
||||
Widget getWidget({
|
||||
String? highlightQuery,
|
||||
TextStyle? style,
|
||||
bool renderMarkdown = false,
|
||||
}) {
|
||||
final poll = originalData?['object'];
|
||||
if (poll?.type == RichObjectStringObjectType.talkPoll) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.poll_outlined),
|
||||
title: Text(poll!.name),
|
||||
contentPadding: const EdgeInsets.only(left: 10),
|
||||
);
|
||||
}
|
||||
|
||||
final emojiFontSize = standaloneEmojiFontSize(content);
|
||||
final effectiveStyle = emojiFontSize == null
|
||||
? style
|
||||
@@ -64,14 +70,7 @@ class ChatMessage {
|
||||
);
|
||||
}
|
||||
|
||||
if (originalData?['object']?.type == RichObjectStringObjectType.talkPoll) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.poll_outlined),
|
||||
title: Text(originalData!['object']!.name),
|
||||
contentPadding: const EdgeInsets.only(left: 10),
|
||||
);
|
||||
}
|
||||
|
||||
final file = this.file;
|
||||
if (file == null) return contentWidget;
|
||||
|
||||
return Padding(
|
||||
@@ -88,7 +87,7 @@ class ChatMessage {
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Text(
|
||||
file!.name,
|
||||
file.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
@@ -107,7 +106,7 @@ class ChatMessage {
|
||||
errorListener: (value) {},
|
||||
httpHeaders: SessionManager().requireNextcloud().authHeaders,
|
||||
imageUrl:
|
||||
'https://${EndpointData().nextcloud().full()}/index.php/core/preview?fileId=${file!.id}&x=130&y=-1&a=1',
|
||||
'https://${EndpointData().nextcloud().full()}/index.php/core/preview?fileId=${file.id}&x=130&y=-1&a=1',
|
||||
),
|
||||
if (originalMessage != '{file}') ...[
|
||||
SizedBox(height: 5),
|
||||
|
||||
@@ -53,9 +53,11 @@ String markdownToPlainText(String source) {
|
||||
|
||||
final buffer = StringBuffer();
|
||||
_collect(nodes, buffer);
|
||||
return buffer.toString().replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
return buffer.toString().replaceAll(_whitespace, ' ').trim();
|
||||
}
|
||||
|
||||
final _whitespace = RegExp(r'\s+');
|
||||
|
||||
void _collect(List<md.Node> nodes, StringBuffer buffer) {
|
||||
for (final node in nodes) {
|
||||
if (node is md.Text) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import '../../../../widget/large_profile_picture_view.dart';
|
||||
import '../../../../widget/user_avatar.dart';
|
||||
import '../talk_navigator.dart';
|
||||
import '../widgets/notification_level_sheet.dart';
|
||||
import '../widgets/room_actions.dart';
|
||||
import 'participants_list_view.dart';
|
||||
import 'shared_items_view.dart';
|
||||
|
||||
@@ -82,19 +83,8 @@ class _ChatInfoState extends State<ChatInfo> {
|
||||
}
|
||||
|
||||
Future<void> _confirmLeave() async {
|
||||
final closed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => ConfirmDialog(
|
||||
title: 'Talk-Chat verlassen',
|
||||
content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.',
|
||||
confirmButton: 'Verlassen',
|
||||
onConfirmAsync: () async {
|
||||
await LeaveRoom(widget.room.token).run();
|
||||
if (mounted) _refreshList();
|
||||
},
|
||||
),
|
||||
);
|
||||
if (closed == true && mounted) Navigator.of(context).pop();
|
||||
final left = await confirmLeaveRoom(context, widget.room.token);
|
||||
if (left && mounted) Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
// Spreed's POST /room/{token}/avatar requires moderator rights and rejects
|
||||
@@ -245,18 +235,10 @@ class _ChatInfoState extends State<ChatInfo> {
|
||||
SharedItemsView(widget.room, overview: _sharesOverview),
|
||||
),
|
||||
),
|
||||
if (_isFavorite)
|
||||
AsyncListTile(
|
||||
leading: const Icon(Icons.stars_outlined),
|
||||
title: const Text('Von Favoriten entfernen'),
|
||||
onPressed: _toggleFavorite,
|
||||
)
|
||||
else
|
||||
AsyncListTile(
|
||||
leading: const Icon(Icons.star_outline),
|
||||
title: const Text('Zu Favoriten hinzufügen'),
|
||||
onPressed: _toggleFavorite,
|
||||
),
|
||||
FavoriteToggleTile(
|
||||
isFavorite: _isFavorite,
|
||||
onPressed: _toggleFavorite,
|
||||
),
|
||||
NotificationLevelTile(
|
||||
level: _notificationLevel,
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
|
||||
@@ -22,8 +22,8 @@ class ParticipantsListView extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
String lastname(participant) =>
|
||||
participant.displayName.toString().split(' ').last;
|
||||
String lastname(GetParticipantsResponseObject participant) =>
|
||||
participant.displayName.split(' ').last;
|
||||
|
||||
final participants = participantsResponse.data.sorted((a, b) {
|
||||
final typeComparison = a.participantType.index.compareTo(
|
||||
|
||||
@@ -23,19 +23,18 @@ const int _sharedItemsPageSize = 20;
|
||||
/// handled separately (split into Bilder/Videos). `location`/`deckcard` are rich
|
||||
/// objects without a downloadable file, so they have no place in this view.
|
||||
const List<_SharedCategory> _sharedCategories = [
|
||||
_SharedCategory('file', 'Dokumente', _SharedItemsLayout.list),
|
||||
_SharedCategory('voice', 'Sprachnachrichten', _SharedItemsLayout.list),
|
||||
_SharedCategory('audio', 'Audio', _SharedItemsLayout.list),
|
||||
_SharedCategory('recording', 'Aufnahmen', _SharedItemsLayout.list),
|
||||
_SharedCategory('other', 'Sonstiges', _SharedItemsLayout.list),
|
||||
_SharedCategory('file', 'Dokumente'),
|
||||
_SharedCategory('voice', 'Sprachnachrichten'),
|
||||
_SharedCategory('audio', 'Audio'),
|
||||
_SharedCategory('recording', 'Aufnahmen'),
|
||||
_SharedCategory('other', 'Sonstiges'),
|
||||
];
|
||||
|
||||
class _SharedCategory {
|
||||
final String objectType;
|
||||
final String label;
|
||||
final _SharedItemsLayout layout;
|
||||
|
||||
const _SharedCategory(this.objectType, this.label, this.layout);
|
||||
const _SharedCategory(this.objectType, this.label);
|
||||
}
|
||||
|
||||
bool _isVideoItem(GetChatResponseObject item) {
|
||||
@@ -203,7 +202,7 @@ class _SharedItemsViewState extends State<SharedItemsView>
|
||||
_SharedItemsTab(
|
||||
token: widget.room.token,
|
||||
objectType: category.objectType,
|
||||
layout: category.layout,
|
||||
layout: _SharedItemsLayout.list,
|
||||
emptyLabel: 'Keine Einträge',
|
||||
initialPage: _seedFromOverview(raw),
|
||||
),
|
||||
|
||||
@@ -11,7 +11,7 @@ import 'widgets/chat_tile.dart';
|
||||
import 'widgets/user_search_tile.dart';
|
||||
|
||||
class SearchChat extends SearchDelegate<GetRoomResponseObject?> {
|
||||
List<GetRoomResponseObject> chats;
|
||||
final List<GetRoomResponseObject> chats;
|
||||
final void Function(GetRoomResponseObject room)? onTapOverride;
|
||||
|
||||
/// When set, results are extended with a clearly separated "start a new chat"
|
||||
@@ -145,8 +145,8 @@ class _SearchChatResultsState extends State<_SearchChatResults> {
|
||||
widget.chats
|
||||
.where(
|
||||
(e) =>
|
||||
e.displayName.toString().toLowerCase().contains(query) ||
|
||||
e.name.toString().toLowerCase().contains(query),
|
||||
e.displayName.toLowerCase().contains(query) ||
|
||||
e.name.toLowerCase().contains(query),
|
||||
)
|
||||
.toList()
|
||||
..sort((a, b) => b.lastActivity.compareTo(a.lastActivity));
|
||||
|
||||
@@ -2,35 +2,14 @@ import 'package:flutter/material.dart';
|
||||
|
||||
enum BubbleNip { leftTop, rightBottom, none }
|
||||
|
||||
class BubbleEdges {
|
||||
const BubbleEdges.only({
|
||||
this.top = 0,
|
||||
this.bottom = 0,
|
||||
this.left = 0,
|
||||
this.right = 0,
|
||||
});
|
||||
const BubbleEdges.all(double value)
|
||||
: top = value,
|
||||
bottom = value,
|
||||
left = value,
|
||||
right = value;
|
||||
|
||||
final double top;
|
||||
final double bottom;
|
||||
final double left;
|
||||
final double right;
|
||||
|
||||
EdgeInsets toEdgeInsets() => EdgeInsets.fromLTRB(left, top, right, bottom);
|
||||
}
|
||||
|
||||
class BubbleStyle {
|
||||
const BubbleStyle({
|
||||
this.color,
|
||||
this.borderWidth = 0,
|
||||
this.borderColor,
|
||||
this.elevation = 0,
|
||||
this.margin = const BubbleEdges.only(),
|
||||
this.padding = const BubbleEdges.all(8),
|
||||
this.margin = EdgeInsets.zero,
|
||||
this.padding = const EdgeInsets.all(8),
|
||||
this.alignment = Alignment.centerLeft,
|
||||
this.nip = BubbleNip.none,
|
||||
this.borderRadius = 12,
|
||||
@@ -40,23 +19,24 @@ class BubbleStyle {
|
||||
final double borderWidth;
|
||||
final Color? borderColor;
|
||||
final double elevation;
|
||||
final BubbleEdges margin;
|
||||
final BubbleEdges padding;
|
||||
final EdgeInsets margin;
|
||||
final EdgeInsets padding;
|
||||
final Alignment alignment;
|
||||
final BubbleNip nip;
|
||||
final double borderRadius;
|
||||
|
||||
BubbleStyle copyWith({double? borderWidth, Color? borderColor}) => BubbleStyle(
|
||||
color: color,
|
||||
borderWidth: borderWidth ?? this.borderWidth,
|
||||
borderColor: borderColor ?? this.borderColor,
|
||||
elevation: elevation,
|
||||
margin: margin,
|
||||
padding: padding,
|
||||
alignment: alignment,
|
||||
nip: nip,
|
||||
borderRadius: borderRadius,
|
||||
);
|
||||
BubbleStyle copyWith({double? borderWidth, Color? borderColor}) =>
|
||||
BubbleStyle(
|
||||
color: color,
|
||||
borderWidth: borderWidth ?? this.borderWidth,
|
||||
borderColor: borderColor ?? this.borderColor,
|
||||
elevation: elevation,
|
||||
margin: margin,
|
||||
padding: padding,
|
||||
alignment: alignment,
|
||||
nip: nip,
|
||||
borderRadius: borderRadius,
|
||||
);
|
||||
}
|
||||
|
||||
/// The "nip" is faked by flattening one corner so the bubble anchors to
|
||||
@@ -96,7 +76,7 @@ class Bubble extends StatelessWidget {
|
||||
return Align(
|
||||
alignment: style.alignment,
|
||||
child: Container(
|
||||
margin: style.margin.toEdgeInsets(),
|
||||
margin: style.margin,
|
||||
decoration: BoxDecoration(
|
||||
color: style.color,
|
||||
borderRadius: radius,
|
||||
@@ -116,7 +96,7 @@ class Bubble extends StatelessWidget {
|
||||
]
|
||||
: null,
|
||||
),
|
||||
padding: style.padding.toEdgeInsets(),
|
||||
padding: style.padding,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -23,6 +23,10 @@ import 'highlighted_linkify.dart';
|
||||
|
||||
enum SearchHighlight { none, secondary, active }
|
||||
|
||||
const double _kSpacing = 3;
|
||||
const double _kTimeIconSize = 11;
|
||||
const Color _kTimeIconColor = Colors.grey;
|
||||
|
||||
class ChatBubble extends StatefulWidget {
|
||||
final bool isSender;
|
||||
final GetChatResponseObject bubbleData;
|
||||
@@ -30,11 +34,7 @@ class ChatBubble extends StatefulWidget {
|
||||
final bool isRead;
|
||||
final String? selfId;
|
||||
|
||||
final double spacing = 3;
|
||||
final double timeIconSize = 11;
|
||||
final Color timeIconColor = Colors.grey;
|
||||
|
||||
final void Function({bool renew}) refetch;
|
||||
final VoidCallback refetch;
|
||||
|
||||
final String? highlightQuery;
|
||||
final SearchHighlight matchHighlight;
|
||||
@@ -66,7 +66,9 @@ class _ChatBubbleState extends State<ChatBubble>
|
||||
Object? _messageKey;
|
||||
late Widget _messageWidget;
|
||||
|
||||
Offset _position = Offset.zero;
|
||||
// Only the translate offset changes per swipe frame; a notifier keeps those
|
||||
// frames from rebuilding the whole bubble.
|
||||
final ValueNotifier<Offset> _position = ValueNotifier(Offset.zero);
|
||||
Offset _dragStartPosition = Offset.zero;
|
||||
bool _swipeActionArmed = false;
|
||||
|
||||
@@ -90,6 +92,7 @@ class _ChatBubbleState extends State<ChatBubble>
|
||||
@override
|
||||
void dispose() {
|
||||
disposeDownloadTrigger();
|
||||
_position.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -165,19 +168,20 @@ class _ChatBubbleState extends State<ChatBubble>
|
||||
/// `TapGestureRecognizer` does not enter the gesture arena — otherwise
|
||||
/// it competes with (and blocks) the per-link `TapGestureRecognizer`s
|
||||
/// that `HighlightedLinkify` / the Markdown renderer attach to link spans.
|
||||
bool get _hasTapAction {
|
||||
bool get _hasTapAction => _poll != null || message.file != null;
|
||||
|
||||
RichObjectString? get _poll {
|
||||
final obj = message.originalData?['object'];
|
||||
if (obj?.type == RichObjectStringObjectType.talkPoll) return true;
|
||||
return message.file != null;
|
||||
return obj?.type == RichObjectStringObjectType.talkPoll ? obj : null;
|
||||
}
|
||||
|
||||
void _onTap() {
|
||||
final obj = message.originalData?['object'];
|
||||
if (obj?.type == RichObjectStringObjectType.talkPoll) {
|
||||
final obj = _poll;
|
||||
if (obj != null) {
|
||||
showChatBubblePollSheet(
|
||||
context,
|
||||
room: widget.chatData,
|
||||
pollId: int.parse(obj!.id),
|
||||
pollId: int.parse(obj.id),
|
||||
pollName: obj.name,
|
||||
refetch: widget.refetch,
|
||||
);
|
||||
@@ -257,10 +261,7 @@ class _ChatBubbleState extends State<ChatBubble>
|
||||
widget.bubbleData.timestamp * 1000,
|
||||
).formatHm(),
|
||||
textAlign: TextAlign.end,
|
||||
style: TextStyle(
|
||||
color: widget.timeIconColor,
|
||||
fontSize: widget.timeIconSize,
|
||||
),
|
||||
style: const TextStyle(color: _kTimeIconColor, fontSize: _kTimeIconSize),
|
||||
);
|
||||
|
||||
return Column(
|
||||
@@ -270,24 +271,23 @@ class _ChatBubbleState extends State<ChatBubble>
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onHorizontalDragStart: (_) => _dragStartPosition = _position,
|
||||
onHorizontalDragStart: (_) => _dragStartPosition = _position.value,
|
||||
onHorizontalDragUpdate: (details) {
|
||||
if (!widget.bubbleData.isReplyable) return;
|
||||
final dx = details.delta.dx - _dragStartPosition.dx;
|
||||
setState(() {
|
||||
_position = (_position.dx + dx).abs() > 60
|
||||
? Offset(_position.dx, 0)
|
||||
: Offset(_position.dx + dx, 0);
|
||||
});
|
||||
final current = _position.value.dx;
|
||||
_position.value = (current + dx).abs() > 60
|
||||
? Offset(current, 0)
|
||||
: Offset(current + dx, 0);
|
||||
// Beim Überqueren der Action-Schwelle einmalig Haptik feuern,
|
||||
// damit der User physisch spürt: "jetzt löst's beim Loslassen aus".
|
||||
final isArmed = _position.dx.abs() > 50;
|
||||
final isArmed = _position.value.dx.abs() > 50;
|
||||
if (isArmed && !_swipeActionArmed) Haptics.longPress();
|
||||
_swipeActionArmed = isArmed;
|
||||
},
|
||||
onHorizontalDragEnd: (_) {
|
||||
final isAction = _position.dx.abs() > 50;
|
||||
setState(() => _position = Offset.zero);
|
||||
final isAction = _position.value.dx.abs() > 50;
|
||||
_position.value = Offset.zero;
|
||||
_swipeActionArmed = false;
|
||||
if (widget.bubbleData.isReplyable && isAction) {
|
||||
context.read<ChatBloc>().setReferenceMessageId(
|
||||
@@ -298,8 +298,10 @@ class _ChatBubbleState extends State<ChatBubble>
|
||||
onLongPress: _showOptionsDialog,
|
||||
onDoubleTap: _showOptionsDialog,
|
||||
onTap: _hasTapAction ? _onTap : null,
|
||||
child: Transform.translate(
|
||||
offset: _position,
|
||||
child: ValueListenableBuilder<Offset>(
|
||||
valueListenable: _position,
|
||||
builder: (context, offset, child) =>
|
||||
Transform.translate(offset: offset, child: child),
|
||||
child: Bubble(
|
||||
style: _getStyle(),
|
||||
child: _BubbleContent(
|
||||
@@ -312,9 +314,6 @@ class _ChatBubbleState extends State<ChatBubble>
|
||||
isSender: widget.isSender,
|
||||
isRead: widget.isRead,
|
||||
selfId: widget.selfId,
|
||||
spacing: widget.spacing,
|
||||
timeIconSize: widget.timeIconSize,
|
||||
timeIconColor: widget.timeIconColor,
|
||||
showActorDisplayName: showActorDisplayName,
|
||||
showBubbleTime: showBubbleTime,
|
||||
downloadJob: downloadJob,
|
||||
@@ -343,9 +342,6 @@ class _BubbleContent extends StatelessWidget {
|
||||
final bool isSender;
|
||||
final bool isRead;
|
||||
final String? selfId;
|
||||
final double spacing;
|
||||
final double timeIconSize;
|
||||
final Color timeIconColor;
|
||||
final bool showActorDisplayName;
|
||||
final bool showBubbleTime;
|
||||
final DownloadJob? downloadJob;
|
||||
@@ -360,9 +356,6 @@ class _BubbleContent extends StatelessWidget {
|
||||
required this.isSender,
|
||||
required this.isRead,
|
||||
required this.selfId,
|
||||
required this.spacing,
|
||||
required this.timeIconSize,
|
||||
required this.timeIconColor,
|
||||
required this.showActorDisplayName,
|
||||
required this.showBubbleTime,
|
||||
required this.downloadJob,
|
||||
@@ -376,7 +369,7 @@ class _BubbleContent extends StatelessWidget {
|
||||
minWidth: showActorDisplayName
|
||||
? actorText.measuredWidth(context)
|
||||
: timeText.measuredWidth(context) +
|
||||
(isSender ? spacing + timeIconSize : 0) +
|
||||
(isSender ? _kSpacing + _kTimeIconSize : 0) +
|
||||
3,
|
||||
),
|
||||
child: Stack(
|
||||
@@ -409,11 +402,11 @@ class _BubbleContent extends StatelessWidget {
|
||||
children: [
|
||||
timeText,
|
||||
if (isSender) ...[
|
||||
SizedBox(width: spacing),
|
||||
const SizedBox(width: _kSpacing),
|
||||
Icon(
|
||||
isRead ? Icons.done_all_outlined : Icons.done_outlined,
|
||||
size: timeIconSize,
|
||||
color: timeIconColor,
|
||||
size: _kTimeIconSize,
|
||||
color: _kTimeIconColor,
|
||||
semanticLabel: isRead
|
||||
? A11yLabels.messageRead
|
||||
: A11yLabels.messageSent,
|
||||
|
||||
@@ -16,7 +16,7 @@ void showChatBubblePollSheet(
|
||||
required GetRoomResponseObject room,
|
||||
required int pollId,
|
||||
required String pollName,
|
||||
required void Function({bool renew}) refetch,
|
||||
required VoidCallback refetch,
|
||||
}) {
|
||||
showDetailsBottomSheet(
|
||||
context,
|
||||
@@ -36,7 +36,7 @@ void showChatBubblePollSheet(
|
||||
class _PollSheetBody extends StatefulWidget {
|
||||
final GetRoomResponseObject room;
|
||||
final int pollId;
|
||||
final void Function({bool renew}) refetch;
|
||||
final VoidCallback refetch;
|
||||
|
||||
const _PollSheetBody({
|
||||
required this.room,
|
||||
@@ -49,21 +49,13 @@ class _PollSheetBody extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _PollSheetBodyState extends State<_PollSheetBody> {
|
||||
Future<GetPollStateResponse>? _future;
|
||||
late Future<GetPollStateResponse> _future = _fetch();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = GetPollState(token: widget.room.token, pollId: widget.pollId).run();
|
||||
}
|
||||
Future<GetPollStateResponse> _fetch() =>
|
||||
GetPollState(token: widget.room.token, pollId: widget.pollId).run();
|
||||
|
||||
void _reload() {
|
||||
setState(() {
|
||||
_future = GetPollState(
|
||||
token: widget.room.token,
|
||||
pollId: widget.pollId,
|
||||
).run();
|
||||
});
|
||||
setState(() => _future = _fetch());
|
||||
}
|
||||
|
||||
void _applyState(GetPollStateResponse state) {
|
||||
@@ -71,7 +63,7 @@ class _PollSheetBodyState extends State<_PollSheetBody> {
|
||||
// Block body: an arrow would return the Future, which setState rejects.
|
||||
_future = Future.value(state);
|
||||
});
|
||||
widget.refetch(renew: true);
|
||||
widget.refetch();
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -17,7 +17,7 @@ class ChatBubbleReactions extends StatelessWidget {
|
||||
final GetChatResponseObject bubbleData;
|
||||
final GetRoomResponseObject chatData;
|
||||
final bool isSender;
|
||||
final void Function({bool renew}) onChanged;
|
||||
final VoidCallback onChanged;
|
||||
|
||||
const ChatBubbleReactions({
|
||||
required this.bubbleData,
|
||||
@@ -58,7 +58,7 @@ class ChatBubbleReactions extends StatelessWidget {
|
||||
params: ReactMessageParams(e.key),
|
||||
).run();
|
||||
}
|
||||
onChanged(renew: true);
|
||||
onChanged();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ void showChatMessageOptionsDialog(
|
||||
required GetRoomResponseObject chatData,
|
||||
required GetChatResponseObject bubbleData,
|
||||
required bool isSender,
|
||||
required void Function({bool renew}) onRefetch,
|
||||
required VoidCallback onRefetch,
|
||||
}) {
|
||||
final parentContext = context;
|
||||
final canReact =
|
||||
@@ -196,7 +196,7 @@ class _ReactionsRow extends StatefulWidget {
|
||||
final int messageId;
|
||||
final List<String> reactions;
|
||||
final List<String> messageReactions;
|
||||
final void Function({bool renew}) onRefetch;
|
||||
final VoidCallback onRefetch;
|
||||
final BuildContext sheetContext;
|
||||
|
||||
const _ReactionsRow({
|
||||
@@ -232,7 +232,7 @@ class _ReactionsRowState extends State<_ReactionsRow> {
|
||||
});
|
||||
if (!mounted) return;
|
||||
if (ok) {
|
||||
widget.onRefetch(renew: true);
|
||||
widget.onRefetch();
|
||||
if (widget.sheetContext.mounted) Navigator.of(widget.sheetContext).pop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,9 +105,7 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
||||
if (messageId != null) {
|
||||
talkSettings.draftReplies[widget.sendToToken] = messageId;
|
||||
} else {
|
||||
talkSettings.draftReplies.removeWhere(
|
||||
(key, _) => key == widget.sendToToken,
|
||||
);
|
||||
talkSettings.draftReplies.remove(widget.sendToToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,43 +283,36 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chatBloc = context.read<ChatBloc>();
|
||||
// Only the reply reference is rendered from the chat state; loading flags
|
||||
// and paging emits don't need to rebuild the input.
|
||||
final (referenceMessageId, chatResponse) = context.select(
|
||||
(ChatBloc b) => (
|
||||
b.state.data?.referenceMessageId,
|
||||
b.state.data?.chatResponse,
|
||||
),
|
||||
);
|
||||
// Only the resolved reply reference is rendered from the chat state;
|
||||
// incoming messages, loading flags and paging emits don't rebuild the input.
|
||||
final referenceMessage = context.select((ChatBloc b) {
|
||||
final data = b.state.data;
|
||||
final id = data?.referenceMessageId;
|
||||
if (id == null) return null;
|
||||
return data!.chatResponse?.data.where((e) => e.id == id).firstOrNull;
|
||||
});
|
||||
|
||||
Widget replyBanner = const SizedBox.shrink();
|
||||
if (referenceMessageId != null && chatResponse != null) {
|
||||
try {
|
||||
final referenceMessage = chatResponse.data.firstWhere(
|
||||
(e) => e.id == referenceMessageId,
|
||||
);
|
||||
replyBanner = Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AnswerReference(
|
||||
referenceMessage: referenceMessage,
|
||||
selfId: widget.selfId,
|
||||
),
|
||||
if (referenceMessage != null) {
|
||||
replyBanner = Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AnswerReference(
|
||||
referenceMessage: referenceMessage,
|
||||
selfId: widget.selfId,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Antwort verwerfen',
|
||||
onPressed: () {
|
||||
chatBloc.setReferenceMessageId(null);
|
||||
_setDraftReply(null);
|
||||
},
|
||||
icon: const Icon(Icons.close_outlined),
|
||||
padding: const EdgeInsets.only(left: 0),
|
||||
),
|
||||
],
|
||||
);
|
||||
} catch (_) {
|
||||
/* reference no longer in current chat data */
|
||||
}
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Antwort verwerfen',
|
||||
onPressed: () {
|
||||
chatBloc.setReferenceMessageId(null);
|
||||
_setDraftReply(null);
|
||||
},
|
||||
icon: const Icon(Icons.close_outlined),
|
||||
padding: const EdgeInsets.only(left: 0),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _publishHeight());
|
||||
|
||||
@@ -17,13 +17,13 @@ import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||
import '../../../../utils/haptics.dart';
|
||||
import '../../../../widget/a11y/a11y_labels.dart';
|
||||
import '../../../../widget/async_action_button.dart';
|
||||
import '../../../../widget/confirm_dialog.dart';
|
||||
import '../../../../widget/debug/debug_tile.dart';
|
||||
import '../../../../widget/details_bottom_sheet.dart';
|
||||
import '../../../../widget/user_avatar.dart';
|
||||
import '../data/talk_markdown.dart';
|
||||
import '../talk_navigator.dart';
|
||||
import 'notification_level_sheet.dart';
|
||||
import 'room_actions.dart';
|
||||
|
||||
class ChatTile extends StatefulWidget {
|
||||
final GetRoomResponseObject data;
|
||||
@@ -48,11 +48,12 @@ class ChatTile extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ChatTileState extends State<ChatTile> {
|
||||
String? selfUsername;
|
||||
String? selfUsername = SessionManager().current?.nextcloud?.username;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (selfUsername != null) return;
|
||||
SessionManager().waitForLoad().then((session) {
|
||||
if (!mounted) return;
|
||||
setState(() => selfUsername = session?.nextcloud?.username);
|
||||
@@ -124,9 +125,8 @@ class _ChatTileState extends State<ChatTile> {
|
||||
label: isGroup ? A11yLabels.group : null,
|
||||
child: ExcludeSemantics(child: circleAvatar),
|
||||
),
|
||||
Visibility(
|
||||
visible: widget.data.isFavorite,
|
||||
child: Positioned(
|
||||
if (widget.data.isFavorite)
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
@@ -143,7 +143,6 @@ class _ChatTileState extends State<ChatTile> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Row(
|
||||
@@ -241,24 +240,16 @@ class _ChatTileState extends State<ChatTile> {
|
||||
if (mounted) _refreshList();
|
||||
},
|
||||
),
|
||||
if (widget.data.isFavorite)
|
||||
AsyncListTile(
|
||||
leading: const Icon(Icons.stars_outlined),
|
||||
title: const Text('Von Favoriten entfernen'),
|
||||
onPressed: () async {
|
||||
await SetFavorite(widget.data.token, false).run();
|
||||
if (mounted) _refreshList();
|
||||
},
|
||||
)
|
||||
else
|
||||
AsyncListTile(
|
||||
leading: const Icon(Icons.star_outline),
|
||||
title: const Text('Zu Favoriten hinzufügen'),
|
||||
onPressed: () async {
|
||||
await SetFavorite(widget.data.token, true).run();
|
||||
if (mounted) _refreshList();
|
||||
},
|
||||
),
|
||||
FavoriteToggleTile(
|
||||
isFavorite: widget.data.isFavorite,
|
||||
onPressed: () async {
|
||||
await SetFavorite(
|
||||
widget.data.token,
|
||||
!widget.data.isFavorite,
|
||||
).run();
|
||||
if (mounted) _refreshList();
|
||||
},
|
||||
),
|
||||
NotificationLevelTile(
|
||||
level: widget.data.effectiveNotificationLevel,
|
||||
onTap: () {
|
||||
@@ -275,16 +266,7 @@ class _ChatTileState extends State<ChatTile> {
|
||||
title: const Text('Talk-Chat verlassen'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetCtx).pop();
|
||||
ConfirmDialog(
|
||||
title: 'Talk-Chat verlassen',
|
||||
content:
|
||||
'Du benötigst ggf. eine Einladung um erneut beizutreten.',
|
||||
confirmButton: 'Verlassen',
|
||||
onConfirmAsync: () async {
|
||||
await LeaveRoom(widget.data.token).run();
|
||||
if (mounted) _refreshList();
|
||||
},
|
||||
).asDialog(context);
|
||||
confirmLeaveRoom(context, widget.data.token);
|
||||
},
|
||||
),
|
||||
DebugTile(sheetCtx).jsonData(widget.data.toJson()),
|
||||
|
||||
@@ -62,14 +62,12 @@ class HighlightedLinkify extends StatefulWidget {
|
||||
final String? highlight;
|
||||
final LinkCallback? onOpen;
|
||||
final TextStyle? style;
|
||||
final TextStyle? linkStyle;
|
||||
|
||||
const HighlightedLinkify({
|
||||
required this.text,
|
||||
this.highlight,
|
||||
this.onOpen,
|
||||
this.style,
|
||||
this.linkStyle,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -126,11 +124,7 @@ class _HighlightedLinkifyState extends State<HighlightedLinkify> {
|
||||
// drops link color/underline because TextStyle.merge treats explicit
|
||||
// nulls in the overlay as "leave unchanged".
|
||||
final linkStyle = defaultStyle.merge(
|
||||
widget.linkStyle ??
|
||||
const TextStyle(
|
||||
color: Colors.blue,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
const TextStyle(color: Colors.blue, decoration: TextDecoration.underline),
|
||||
);
|
||||
const linkHighlight = TextStyle(
|
||||
backgroundColor: Color(0xFFFFD54F),
|
||||
|
||||
@@ -78,22 +78,12 @@ class _PollOptionsListState extends State<PollOptionsList> {
|
||||
return !(voted.length == _selected.length && voted.containsAll(_selected));
|
||||
}
|
||||
|
||||
Future<void> _submitVote() async {
|
||||
Future<void> _vote(List<int> optionIds) async {
|
||||
if (guardDemoAction(context)) return;
|
||||
final result = await VotePoll(
|
||||
token: widget.room.token,
|
||||
pollId: widget.pollData.id,
|
||||
params: VotePollParams(optionIds: _selected.toList()..sort()),
|
||||
).run();
|
||||
widget.onStateChanged(result);
|
||||
}
|
||||
|
||||
Future<void> _retractVote() async {
|
||||
if (guardDemoAction(context)) return;
|
||||
final result = await VotePoll(
|
||||
token: widget.room.token,
|
||||
pollId: widget.pollData.id,
|
||||
params: VotePollParams(optionIds: const []),
|
||||
params: VotePollParams(optionIds: optionIds),
|
||||
).run();
|
||||
widget.onStateChanged(result);
|
||||
}
|
||||
@@ -230,22 +220,22 @@ class _PollOptionsListState extends State<PollOptionsList> {
|
||||
|
||||
Widget _primaryButton() {
|
||||
final voted = widget.pollData.votedSelf.isNotEmpty;
|
||||
final canSubmit = _selected.isNotEmpty && _selectionChanged;
|
||||
|
||||
// Retract stays a flat AsyncTextButton – its spinner uses colorScheme.primary
|
||||
// and stays visible on the sheet background, unlike the filled variant's.
|
||||
if (voted && !(_selected.isNotEmpty && _selectionChanged)) {
|
||||
if (voted && !canSubmit) {
|
||||
return AsyncTextButton(
|
||||
controller: _voteController,
|
||||
onPressed: _retractVote,
|
||||
onPressed: () => _vote(const []),
|
||||
showInlineError: false,
|
||||
child: const Text('Zurückziehen'),
|
||||
);
|
||||
}
|
||||
|
||||
final canSubmit = _selected.isNotEmpty && _selectionChanged;
|
||||
return AsyncActionButton(
|
||||
controller: _voteController,
|
||||
onPressed: canSubmit ? _submitVote : null,
|
||||
onPressed: canSubmit ? () => _vote(_selected.toList()..sort()) : null,
|
||||
showInlineError: false,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../api/marianumcloud/talk/actions/talk_actions.dart';
|
||||
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||
import '../../../../widget/async_action_button.dart';
|
||||
import '../../../../widget/confirm_dialog.dart';
|
||||
|
||||
class FavoriteToggleTile extends StatelessWidget {
|
||||
final bool isFavorite;
|
||||
final AsyncActionCallback onPressed;
|
||||
|
||||
const FavoriteToggleTile({
|
||||
required this.isFavorite,
|
||||
required this.onPressed,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AsyncListTile(
|
||||
leading: Icon(isFavorite ? Icons.stars_outlined : Icons.star_outline),
|
||||
title: Text(
|
||||
isFavorite ? 'Von Favoriten entfernen' : 'Zu Favoriten hinzufügen',
|
||||
),
|
||||
onPressed: onPressed,
|
||||
);
|
||||
}
|
||||
|
||||
/// Asks for confirmation, leaves the room and refreshes the chat list.
|
||||
/// Resolves to true once the room was left.
|
||||
Future<bool> confirmLeaveRoom(BuildContext context, String token) async {
|
||||
final chatList = context.read<ChatListBloc>();
|
||||
final left = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => ConfirmDialog(
|
||||
title: 'Talk-Chat verlassen',
|
||||
content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.',
|
||||
confirmButton: 'Verlassen',
|
||||
onConfirmAsync: () async {
|
||||
await LeaveRoom(token).run();
|
||||
unawaited(chatList.refresh());
|
||||
},
|
||||
),
|
||||
);
|
||||
return left ?? false;
|
||||
}
|
||||
Reference in New Issue
Block a user