From aca86c506a0d3ec7e1b86d60c311a65f7b3f80d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 27 Sep 2026 21:13:44 +0200 Subject: [PATCH] fixed countdown wrap, css rgba colors, stale create-link gate and wired up reply drafts in talk --- lib/view/pages/files/sharing/share_sheet.dart | 167 +++++++++--------- .../pages/talk/widgets/chat_textfield.dart | 29 ++- lib/widget/animated_time.dart | 18 +- lib/widget/prosemirror/pm_rich_text.dart | 19 +- .../widget/prosemirror/pm_css_color_test.dart | 29 +++ 5 files changed, 143 insertions(+), 119 deletions(-) create mode 100644 test/widget/prosemirror/pm_css_color_test.dart diff --git a/lib/view/pages/files/sharing/share_sheet.dart b/lib/view/pages/files/sharing/share_sheet.dart index 9108e68..a16fe6f 100644 --- a/lib/view/pages/files/sharing/share_sheet.dart +++ b/lib/view/pages/files/sharing/share_sheet.dart @@ -50,10 +50,6 @@ class _ShareSheetBodyState extends State<_ShareSheetBody> { late Future> _future = FileSharingApi().listForPath(_ocsPath); bool _busy = false; - /// Last resolved share list — used by the create-link gate without depending - /// on the FutureBuilder's snapshot. - List? _lastShares; - void _reload() { // Block body: an arrow would return the Future, which setState rejects. setState(() { @@ -126,103 +122,98 @@ class _ShareSheetBodyState extends State<_ShareSheetBody> { @override Widget build(BuildContext context) { final caps = context.watch(); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - if (_busy) const LinearProgressIndicator(), - _shareList(), - const Divider(height: 1), - if (caps.canShareWithUsers || caps.canShareWithGroups) - ListTile( - leading: const CenteredLeading(Icon(Icons.person_add_outlined)), - title: const Text('Person oder Gruppe hinzufügen'), - enabled: !_busy, - onTap: _addSharee, - ), - if (caps.canCreatePublicLinks && _canAddLink(caps)) - ListTile( - leading: const CenteredLeading(Icon(Icons.add_link)), - title: const Text('Öffentlichen Link erstellen'), - enabled: !_busy, - onTap: _createPublicLink, - ), - ], + return FutureBuilder>( + future: _future, + builder: (context, snapshot) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (_busy) const LinearProgressIndicator(), + _shareList(snapshot), + const Divider(height: 1), + if (caps.canShareWithUsers || caps.canShareWithGroups) + ListTile( + leading: const CenteredLeading(Icon(Icons.person_add_outlined)), + title: const Text('Person oder Gruppe hinzufügen'), + enabled: !_busy, + onTap: _addSharee, + ), + if (caps.canCreatePublicLinks && _canAddLink(caps, snapshot.data)) + ListTile( + leading: const CenteredLeading(Icon(Icons.add_link)), + title: const Text('Öffentlichen Link erstellen'), + enabled: !_busy, + onTap: _createPublicLink, + ), + ], + ), ); } /// Whether another public link may be created — hidden once one exists on a /// server that disallows multiple links. - bool _canAddLink(NextcloudCapabilitiesCubit caps) { - if (caps.allowsMultipleLinks) return true; - final hasLink = _lastShares?.any((s) => s.isPublicLink) ?? false; - return !hasLink; - } + bool _canAddLink(NextcloudCapabilitiesCubit caps, List? shares) => + caps.allowsMultipleLinks || + !(shares?.any((s) => s.isPublicLink) ?? false); - Widget _shareList() { - return FutureBuilder>( - future: _future, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Padding( - padding: EdgeInsets.all(24), - child: Center(child: AppProgressIndicator.medium()), - ); - } - if (snapshot.hasError) { - return Padding( - padding: const EdgeInsets.all(24), + Widget _shareList(AsyncSnapshot> snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Padding( + padding: EdgeInsets.all(24), + child: Center(child: AppProgressIndicator.medium()), + ); + } + if (snapshot.hasError) { + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Text( + errorToUserMessage(snapshot.error), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.tonal( + onPressed: _reload, + child: const Text('Erneut versuchen'), + ), + ], + ), + ); + } + final shares = snapshot.data ?? const []; + if (shares.isEmpty) { + final theme = Theme.of(context); + return SizedBox( + height: 160, + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Text( - errorToUserMessage(snapshot.error), - textAlign: TextAlign.center, + Icon( + Icons.lock_outline, + size: 48, + color: theme.colorScheme.outline, ), const SizedBox(height: 12), - FilledButton.tonal( - onPressed: _reload, - child: const Text('Erneut versuchen'), + Text( + 'Diese Datei ist noch nicht freigegeben.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), ], ), - ); - } - final shares = snapshot.data ?? const []; - _lastShares = shares; - if (shares.isEmpty) { - final theme = Theme.of(context); - return SizedBox( - height: 160, - child: Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.lock_outline, - size: 48, - color: theme.colorScheme.outline, - ), - const SizedBox(height: 12), - Text( - 'Diese Datei ist noch nicht freigegeben.', - textAlign: TextAlign.center, - style: theme.textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ), - ); - } - return Column( - mainAxisSize: MainAxisSize.min, - children: shares.map(_shareTile).toList(), - ); - }, + ), + ), + ); + } + return Column( + mainAxisSize: MainAxisSize.min, + children: shares.map(_shareTile).toList(), ); } diff --git a/lib/view/pages/talk/widgets/chat_textfield.dart b/lib/view/pages/talk/widgets/chat_textfield.dart index 5ff92fe..562bd32 100644 --- a/lib/view/pages/talk/widgets/chat_textfield.dart +++ b/lib/view/pages/talk/widgets/chat_textfield.dart @@ -44,6 +44,7 @@ class _ChatTextfieldState extends State { final AsyncActionController _sendController = AsyncActionController(); final FocusNode _focusNode = FocusNode(); final GlobalKey _sizeKey = GlobalKey(); + late final StreamSubscription _replySubscription; String? _sendError; /// Publishes the input bar's measured height so the downloads chip floats @@ -114,13 +115,20 @@ class _ChatTextfieldState extends State { super.initState(); settings = context.read(); _loadDraft(); - final draftReply = settings - .val() - .talkSettings - .draftReplies[widget.sendToToken]; - if (draftReply != null) { - context.read().setReferenceMessageId(draftReply); + _replySubscription = context.read().stream.listen( + (state) => _persistPickedReply(state.data), + ); + } + + /// Clearing is handled where it happens (send, dismiss); the bloc also + /// drops the reference on every room switch, which must not erase the draft. + void _persistPickedReply(ChatState? chatState) { + final id = chatState?.referenceMessageId; + if (id == null || chatState!.currentToken != widget.sendToToken) return; + if (settings.val().talkSettings.draftReplies[widget.sendToToken] == id) { + return; } + _setDraftReply(id); } @override @@ -137,8 +145,12 @@ class _ChatTextfieldState extends State { /// so doing this per build would drop the cursor position, any active text /// selection and a running IME composition on every chat update. void _loadDraft() { - _textBoxController.text = - settings.val().talkSettings.drafts[widget.sendToToken] ?? ''; + final talkSettings = settings.val().talkSettings; + _textBoxController.text = talkSettings.drafts[widget.sendToToken] ?? ''; + final draftReply = talkSettings.draftReplies[widget.sendToToken]; + if (draftReply != null) { + context.read().setReferenceMessageId(draftReply); + } } @override @@ -148,6 +160,7 @@ class _ChatTextfieldState extends State { WidgetsBinding.instance.addPostFrameCallback( (_) => downloadChipBottomObstruction.value = 0, ); + unawaited(_replySubscription.cancel()); _sendController.dispose(); _focusNode.dispose(); super.dispose(); diff --git a/lib/widget/animated_time.dart b/lib/widget/animated_time.dart index 1ba4ccd..4f8c467 100644 --- a/lib/widget/animated_time.dart +++ b/lib/widget/animated_time.dart @@ -33,23 +33,11 @@ class _AnimatedTimeState extends State { const Text('Noch '), buildWidget(current.inDays), const Text(' Tage, '), - buildWidget( - current.inHours > 24 - ? current.inHours - current.inDays * 24 - : current.inHours, - ), + buildWidget(current.inHours.remainder(24)), const Text(':'), - buildWidget( - current.inMinutes > 60 - ? current.inMinutes - current.inHours * 60 - : current.inMinutes, - ), + buildWidget(current.inMinutes.remainder(60)), const Text(':'), - buildWidget( - current.inSeconds > 60 - ? current.inSeconds - current.inMinutes * 60 - : current.inSeconds, - ), + buildWidget(current.inSeconds.remainder(60)), ], ); diff --git a/lib/widget/prosemirror/pm_rich_text.dart b/lib/widget/prosemirror/pm_rich_text.dart index d44cd34..f0a9eaf 100644 --- a/lib/widget/prosemirror/pm_rich_text.dart +++ b/lib/widget/prosemirror/pm_rich_text.dart @@ -142,7 +142,7 @@ class _PmRichTextState extends State { } Color _highlightColor(dynamic color, ThemeData theme) { - final parsed = color is String ? _parseCssColor(color) : null; + final parsed = color is String ? parseCssColor(color) : null; final base = parsed ?? theme.colorScheme.tertiaryContainer; // Full-opacity highlights swamp the text in dark mode, so dial the alpha // down further there while keeping the accent readable in light mode. @@ -178,19 +178,22 @@ class _PmRichTextState extends State { /// Parses a CSS `#rgb`/`#rrggbb`/`#rrggbbaa` hex or a small set of named colors. /// Returns `null` for anything unrecognised so the caller can fall back. -Color? _parseCssColor(String raw) { +@visibleForTesting +Color? parseCssColor(String raw) { final value = raw.trim().toLowerCase(); if (value.startsWith('#')) { var hex = value.substring(1); if (hex.length == 3) { hex = hex.split('').map((c) => '$c$c').join(); } - if (hex.length == 6) hex = 'ff$hex'; - if (hex.length == 8) { - final rgba = int.tryParse(hex, radix: 16); - if (rgba != null) return Color(rgba); - } - return null; + // CSS puts alpha last, Flutter's Color expects it first. + final argb = switch (hex.length) { + 6 => 'ff$hex', + 8 => hex.substring(6) + hex.substring(0, 6), + _ => null, + }; + final parsed = argb == null ? null : int.tryParse(argb, radix: 16); + return parsed == null ? null : Color(parsed); } return _namedColors[value]; } diff --git a/test/widget/prosemirror/pm_css_color_test.dart b/test/widget/prosemirror/pm_css_color_test.dart new file mode 100644 index 0000000..007915a --- /dev/null +++ b/test/widget/prosemirror/pm_css_color_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/widget/prosemirror/pm_rich_text.dart'; + +void main() { + group('parseCssColor', () { + test('expands #rgb', () { + expect(parseCssColor('#f00'), const Color(0xFFFF0000)); + }); + + test('parses #rrggbb as opaque', () { + expect(parseCssColor(' #3B82F6 '), const Color(0xFF3B82F6)); + }); + + test('reads #rrggbbaa with alpha last', () { + expect(parseCssColor('#3b82f680'), const Color(0x803B82F6)); + }); + + test('resolves named colors', () { + expect(parseCssColor('Red'), const Color(0xFFEF4444)); + }); + + test('rejects malformed input', () { + expect(parseCssColor('#12345'), isNull); + expect(parseCssColor('#zzzzzz'), isNull); + expect(parseCssColor('rgb(0,0,0)'), isNull); + }); + }); +}