simplified code across the app and consolidated duplicated helpers

This commit is contained in:
2026-09-27 17:29:54 +02:00
parent 3e856d0b1c
commit ee1240f71a
155 changed files with 1772 additions and 2546 deletions
+18 -38
View File
@@ -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,
),
);
+33 -40
View File
@@ -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();
}
}
+28 -37
View File
@@ -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());
+16 -34
View File
@@ -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;
}