From 4c190de479c08ed225ce65a2c99160dd809cae67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sat, 9 May 2026 22:21:36 +0200 Subject: [PATCH 01/11] implemented in-chat search with text highlighting, added search navigation UI, and integrated scrollable list for message jumping --- lib/view/pages/talk/chat_view.dart | 218 ++++++++++++++++-- lib/view/pages/talk/data/chat_message.dart | 11 +- .../talk/data/chat_search_controller.dart | 49 ++++ lib/view/pages/talk/widgets/bubble.dart | 16 +- lib/view/pages/talk/widgets/chat_bubble.dart | 68 +++++- .../talk/widgets/chat_search_app_bar.dart | 67 ++++++ .../talk/widgets/highlighted_linkify.dart | 140 +++++++++++ pubspec.yaml | 2 + .../talk/chat_search_controller_test.dart | 163 +++++++++++++ 9 files changed, 698 insertions(+), 36 deletions(-) create mode 100644 lib/view/pages/talk/data/chat_search_controller.dart create mode 100644 lib/view/pages/talk/widgets/chat_search_app_bar.dart create mode 100644 lib/view/pages/talk/widgets/highlighted_linkify.dart create mode 100644 test/view/talk/chat_search_controller_test.dart diff --git a/lib/view/pages/talk/chat_view.dart b/lib/view/pages/talk/chat_view.dart index 2c81c48..ccf3a07 100644 --- a/lib/view/pages/talk/chat_view.dart +++ b/lib/view/pages/talk/chat_view.dart @@ -1,5 +1,8 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../../api/marianumcloud/talk/chat/get_chat_response.dart'; import '../../../api/marianumcloud/talk/room/get_room_response.dart'; @@ -10,9 +13,11 @@ import '../../../state/app/modules/chat/bloc/chat_state.dart'; import '../../../theming/app_theme.dart'; import '../../../widget/clickable_app_bar.dart'; import '../../../widget/user_avatar.dart'; +import 'data/chat_search_controller.dart'; import 'details/chat_info.dart'; import 'talk_navigator.dart'; import 'widgets/chat_bubble.dart'; +import 'widgets/chat_search_app_bar.dart'; import 'widgets/chat_textfield.dart'; class ChatView extends StatefulWidget { @@ -32,14 +37,139 @@ class ChatView extends StatefulWidget { } class _ChatViewState extends State { - final ScrollController _listController = ScrollController(); + final ItemScrollController _itemScrollController = ItemScrollController(); + final TextEditingController _searchTextController = TextEditingController(); + final Map _matchIndices = {}; + + bool _searchActive = false; + String _searchQuery = ''; + List _matches = const []; + int _activeMatchIndex = 0; + GetChatResponse? _matchesComputedFor; + String? _matchesComputedQuery; + + @override + void dispose() { + _searchTextController.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant ChatView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.room.token != oldWidget.room.token && _searchActive) { + _exitSearchMode(); + } + } void _refresh() { context.read().setToken(widget.room.token); } + void _enterSearchMode() { + setState(() { + _searchActive = true; + _searchQuery = ''; + _matches = const []; + _activeMatchIndex = 0; + _matchesComputedFor = null; + _matchesComputedQuery = null; + _searchTextController.clear(); + }); + } + + void _exitSearchMode() { + setState(() { + _searchActive = false; + _searchQuery = ''; + _matches = const []; + _activeMatchIndex = 0; + _matchIndices.clear(); + _matchesComputedFor = null; + _matchesComputedQuery = null; + _searchTextController.clear(); + }); + } + + void _onSearchChanged(String q) { + final chatResponse = context.read().state.data?.chatResponse; + setState(() { + _searchQuery = q; + _activeMatchIndex = 0; + if (chatResponse != null) { + _recomputeMatches(chatResponse); + } else { + _matches = const []; + _matchesComputedFor = null; + _matchesComputedQuery = null; + } + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _matches.isNotEmpty) _scrollToActiveMatch(); + }); + } + + void _recomputeMatches(GetChatResponse response) { + _matches = ChatSearchController.findMatches(response, _searchQuery); + _activeMatchIndex = _activeMatchIndex.clamp( + 0, + math.max(0, _matches.length - 1), + ); + _matchesComputedFor = response; + _matchesComputedQuery = _searchQuery; + } + + void _goToPreviousMatch() { + if (_matches.isEmpty) return; + setState(() { + _activeMatchIndex = (_activeMatchIndex + 1) % _matches.length; + }); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _scrollToActiveMatch(), + ); + } + + void _goToNextMatch() { + if (_matches.isEmpty) return; + setState(() { + _activeMatchIndex = + (_activeMatchIndex - 1 + _matches.length) % _matches.length; + }); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _scrollToActiveMatch(), + ); + } + + void _scrollToActiveMatch() { + if (_matches.isEmpty) return; + if (!_itemScrollController.isAttached) return; + final id = _matches[_activeMatchIndex].messageId; + final idx = _matchIndices[id]; + if (idx == null) return; + _itemScrollController.scrollTo( + index: idx, + alignment: 0.4, + duration: const Duration(milliseconds: 350), + curve: Curves.easeInOut, + ); + } + List _buildMessages(GetChatResponse response) { + if (_searchActive && + (response != _matchesComputedFor || + _searchQuery != _matchesComputedQuery)) { + _recomputeMatches(response); + } + + final matchIds = _matches.map((m) => m.messageId).toSet(); + final activeId = _matches.isNotEmpty + ? _matches[_activeMatchIndex].messageId + : null; + final highlightQuery = + _searchActive && _searchQuery.trim().isNotEmpty ? _searchQuery : null; + final messages = []; + final chronologicalMatchIndex = {}; var lastDate = DateTime.now(); for (final element in response.sortByTimestamp()) { final elementDate = DateTime.fromMillisecondsSinceEpoch( @@ -65,6 +195,15 @@ class _ChatViewState extends State { ); } + final isMatch = matchIds.contains(element.id); + final highlight = isMatch + ? (element.id == activeId + ? SearchHighlight.active + : SearchHighlight.secondary) + : SearchHighlight.none; + + if (isMatch) chronologicalMatchIndex[element.id] = messages.length; + messages.add( ChatBubble( context: context, @@ -76,6 +215,8 @@ class _ChatViewState extends State { refetch: ({bool renew = false}) => _refresh(), isRead: element.id <= commonRead, selfId: widget.selfId, + highlightQuery: highlightQuery, + matchHighlight: highlight, ), ); } @@ -94,32 +235,60 @@ class _ChatViewState extends State { refetch: ({bool renew = false}) => _refresh(), ), ); + chronologicalMatchIndex.updateAll((_, v) => v + 1); } + final total = messages.length; + _matchIndices + ..clear() + ..addEntries( + chronologicalMatchIndex.entries.map( + (e) => MapEntry(e.key, (total - 1) - e.value), + ), + ); + return messages; } @override Widget build(BuildContext context) => Scaffold( backgroundColor: const Color(0xffefeae2), - 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, + appBar: _searchActive + ? ChatSearchAppBar( + controller: _searchTextController, + matchCount: _matches.length, + activeIndex: _matches.isEmpty ? -1 : _activeMatchIndex, + onChanged: _onSearchChanged, + onClose: _exitSearchMode, + onPrevious: _matches.isEmpty ? null : _goToPreviousMatch, + onNext: _matches.isEmpty ? null : _goToNextMatch, + ) + : 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, + ), + ), + ], ), + actions: [ + IconButton( + icon: const Icon(Icons.search), + tooltip: 'In Chat suchen', + onPressed: _enterSearchMode, + ), + ], ), - ], - ), - ), - ), + ), body: DecoratedBox( decoration: BoxDecoration( image: DecorationImage( @@ -137,11 +306,16 @@ class _ChatViewState extends State { isReady: (state) => state.chatResponse != null && state.currentToken == widget.room.token, - child: (state, _) => ListView( - reverse: true, - controller: _listController, - children: _buildMessages(state.chatResponse!).reversed.toList(), - ), + child: (state, _) { + final items = + _buildMessages(state.chatResponse!).reversed.toList(); + return ScrollablePositionedList.builder( + reverse: true, + itemScrollController: _itemScrollController, + itemCount: items.length, + itemBuilder: (ctx, idx) => items[idx], + ); + }, ), ), ColoredBox( diff --git a/lib/view/pages/talk/data/chat_message.dart b/lib/view/pages/talk/data/chat_message.dart index 30ec493..bba5ae8 100644 --- a/lib/view/pages/talk/data/chat_message.dart +++ b/lib/view/pages/talk/data/chat_message.dart @@ -1,12 +1,12 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_linkify/flutter_linkify.dart'; import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart'; import '../../../../model/account_data.dart'; import '../../../../model/endpoint_data.dart'; import '../../../../utils/url_opener.dart'; +import '../widgets/highlighted_linkify.dart'; class ChatMessage { String originalMessage; @@ -27,8 +27,13 @@ class ChatMessage { ); } - Widget getWidget() { - var contentWidget = Linkify(text: content, onOpen: UrlOpener.onOpen); + Widget getWidget({String? highlightQuery, TextStyle? style}) { + var contentWidget = HighlightedLinkify( + text: content, + onOpen: UrlOpener.onOpen, + highlight: highlightQuery, + style: style, + ); if (originalData?['object']?.type == RichObjectStringObjectType.talkPoll) { return ListTile( diff --git a/lib/view/pages/talk/data/chat_search_controller.dart b/lib/view/pages/talk/data/chat_search_controller.dart new file mode 100644 index 0000000..7d9e54b --- /dev/null +++ b/lib/view/pages/talk/data/chat_search_controller.dart @@ -0,0 +1,49 @@ +import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart'; +import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; + +class ChatSearchMatch { + final int messageId; + final int timestamp; + + const ChatSearchMatch({required this.messageId, required this.timestamp}); +} + +class ChatSearchController { + static List findMatches( + GetChatResponse response, + String query, + ) { + final q = query.trim().toLowerCase(); + if (q.isEmpty) return const []; + + final matches = []; + for (final element in response.sortByTimestamp()) { + if (element.systemMessage.contains('reaction')) continue; + if (element.systemMessage.contains('poll_voted')) continue; + + final haystackText = RichObjectStringProcessor.parseToString( + element.message, + element.messageParameters, + ).toLowerCase(); + + var matched = haystackText.contains(q); + if (!matched && + element.messageType != GetRoomResponseObjectMessageType.system) { + matched = element.actorDisplayName.toLowerCase().contains(q); + } + + if (matched) { + matches.add( + ChatSearchMatch( + messageId: element.id, + timestamp: element.timestamp, + ), + ); + } + } + + matches.sort((a, b) => b.timestamp.compareTo(a.timestamp)); + return matches; + } +} diff --git a/lib/view/pages/talk/widgets/bubble.dart b/lib/view/pages/talk/widgets/bubble.dart index 30d1fac..436fa05 100644 --- a/lib/view/pages/talk/widgets/bubble.dart +++ b/lib/view/pages/talk/widgets/bubble.dart @@ -27,6 +27,7 @@ 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), @@ -37,12 +38,25 @@ class BubbleStyle { final Color? color; final double borderWidth; + final Color? borderColor; final double elevation; final BubbleEdges margin; final BubbleEdges 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, + ); } /// The "nip" is faked by flattening one corner so the bubble anchors to @@ -88,7 +102,7 @@ class Bubble extends StatelessWidget { borderRadius: radius, border: style.borderWidth > 0 ? Border.all( - color: Theme.of(context).dividerColor, + color: style.borderColor ?? Theme.of(context).dividerColor, width: style.borderWidth, ) : null, diff --git a/lib/view/pages/talk/widgets/chat_bubble.dart b/lib/view/pages/talk/widgets/chat_bubble.dart index 9e5079f..a100a20 100644 --- a/lib/view/pages/talk/widgets/chat_bubble.dart +++ b/lib/view/pages/talk/widgets/chat_bubble.dart @@ -18,6 +18,9 @@ import 'bubble.dart'; import 'chat_bubble_poll.dart'; import 'chat_bubble_reactions.dart'; import 'chat_message_options_dialog.dart'; +import 'highlighted_linkify.dart'; + +enum SearchHighlight { none, secondary, active } class ChatBubble extends StatefulWidget { final BuildContext context; @@ -33,6 +36,9 @@ class ChatBubble extends StatefulWidget { final void Function({bool renew}) refetch; + final String? highlightQuery; + final SearchHighlight matchHighlight; + const ChatBubble({ required this.context, required this.isSender, @@ -41,6 +47,8 @@ class ChatBubble extends StatefulWidget { required this.refetch, this.isRead = false, this.selfId, + this.highlightQuery, + this.matchHighlight = SearchHighlight.none, super.key, }); @@ -142,13 +150,29 @@ class _ChatBubbleState extends State BubbleStyle _getStyle() { final styles = ChatBubbleStyles(context); + final BubbleStyle base; if (widget.bubbleData.messageType != GetRoomResponseObjectMessageType.comment) { - return styles.getSystemStyle(); + base = styles.getSystemStyle(); + } else { + base = widget.isSender + ? styles.getSelfStyle(false) + : styles.getRemoteStyle(false); + } + switch (widget.matchHighlight) { + case SearchHighlight.none: + return base; + case SearchHighlight.secondary: + return base.copyWith( + borderWidth: 1.5, + borderColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.45), + ); + case SearchHighlight.active: + return base.copyWith( + borderWidth: 3, + borderColor: Theme.of(context).colorScheme.primary, + ); } - return widget.isSender - ? styles.getSelfStyle(false) - : styles.getRemoteStyle(false); } void _showOptionsDialog() => showChatMessageOptionsDialog( @@ -196,15 +220,29 @@ class _ChatBubbleState extends State GetRoomResponseObjectMessageType.deletedComment; final parent = widget.bubbleData.parent; + final actorBaseStyle = TextStyle( + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.bold, + ); final actorText = Text( widget.bubbleData.actorDisplayName, textAlign: TextAlign.start, overflow: TextOverflow.ellipsis, - style: TextStyle( - color: Theme.of(context).primaryColor, - fontWeight: FontWeight.bold, - ), + style: actorBaseStyle, ); + final actorWidget = (widget.highlightQuery?.trim().isNotEmpty ?? false) + ? Text.rich( + TextSpan( + children: buildHighlightedSpans( + text: widget.bubbleData.actorDisplayName, + query: widget.highlightQuery, + baseStyle: actorBaseStyle, + ), + ), + textAlign: TextAlign.start, + overflow: TextOverflow.ellipsis, + ) + : actorText; final timeText = Text( DateTime.fromMillisecondsSinceEpoch( @@ -252,8 +290,16 @@ class _ChatBubbleState extends State style: _getStyle(), child: _BubbleContent( actorText: actorText, + actorWidget: actorWidget, timeText: timeText, - messageWidget: message.getWidget(), + messageWidget: message.getWidget( + highlightQuery: widget.highlightQuery, + style: + widget.bubbleData.messageType == + GetRoomResponseObjectMessageType.system + ? Theme.of(context).textTheme.bodySmall + : null, + ), parent: parent, bubbleData: widget.bubbleData, isSender: widget.isSender, @@ -282,6 +328,7 @@ class _ChatBubbleState extends State class _BubbleContent extends StatelessWidget { final Text actorText; + final Widget actorWidget; final Text timeText; final Widget messageWidget; final GetChatResponseObject? parent; @@ -298,6 +345,7 @@ class _BubbleContent extends StatelessWidget { const _BubbleContent({ required this.actorText, + required this.actorWidget, required this.timeText, required this.messageWidget, required this.parent, @@ -323,7 +371,7 @@ class _BubbleContent extends StatelessWidget { ), child: Stack( children: [ - if (showActorDisplayName) Positioned(top: 0, left: 0, child: actorText), + if (showActorDisplayName) Positioned(top: 0, left: 0, child: actorWidget), Padding( padding: EdgeInsets.only( bottom: showBubbleTime ? 18 : 0, diff --git a/lib/view/pages/talk/widgets/chat_search_app_bar.dart b/lib/view/pages/talk/widgets/chat_search_app_bar.dart new file mode 100644 index 0000000..0b1c9f7 --- /dev/null +++ b/lib/view/pages/talk/widgets/chat_search_app_bar.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +class ChatSearchAppBar extends StatelessWidget implements PreferredSizeWidget { + final TextEditingController controller; + final int matchCount; + final int activeIndex; + final ValueChanged onChanged; + final VoidCallback onClose; + final VoidCallback? onPrevious; + final VoidCallback? onNext; + + const ChatSearchAppBar({ + required this.controller, + required this.matchCount, + required this.activeIndex, + required this.onChanged, + required this.onClose, + required this.onPrevious, + required this.onNext, + super.key, + }); + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); + + @override + Widget build(BuildContext context) { + final counterText = matchCount == 0 + ? '0/0' + : '${activeIndex + 1}/$matchCount'; + return AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: onClose, + ), + title: TextField( + controller: controller, + autofocus: true, + textInputAction: TextInputAction.search, + decoration: const InputDecoration( + hintText: 'In Chat suchen…', + border: InputBorder.none, + ), + onChanged: onChanged, + ), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text( + counterText, + style: const TextStyle(fontSize: 12), + ), + ), + ), + IconButton( + icon: const Icon(Icons.keyboard_arrow_up), + onPressed: onPrevious, + ), + IconButton( + icon: const Icon(Icons.keyboard_arrow_down), + onPressed: onNext, + ), + ], + ); + } +} diff --git a/lib/view/pages/talk/widgets/highlighted_linkify.dart b/lib/view/pages/talk/widgets/highlighted_linkify.dart new file mode 100644 index 0000000..95281ac --- /dev/null +++ b/lib/view/pages/talk/widgets/highlighted_linkify.dart @@ -0,0 +1,140 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_linkify/flutter_linkify.dart'; +import 'package:linkify/linkify.dart' as linkify_pkg; + +const TextStyle kSearchHighlightStyle = TextStyle( + backgroundColor: Color(0xFFFFD54F), + color: Colors.black, +); + +List buildHighlightedSpans({ + required String text, + required String? query, + required TextStyle baseStyle, + TextStyle highlightStyle = kSearchHighlightStyle, + GestureRecognizer? recognizer, +}) { + final q = query?.trim().toLowerCase(); + if (q == null || q.isEmpty) { + return [TextSpan(text: text, style: baseStyle, recognizer: recognizer)]; + } + + final spans = []; + final lower = text.toLowerCase(); + var cursor = 0; + while (cursor < text.length) { + final hit = lower.indexOf(q, cursor); + if (hit < 0) { + spans.add( + TextSpan( + text: text.substring(cursor), + style: baseStyle, + recognizer: recognizer, + ), + ); + break; + } + if (hit > cursor) { + spans.add( + TextSpan( + text: text.substring(cursor, hit), + style: baseStyle, + recognizer: recognizer, + ), + ); + } + final end = hit + q.length; + spans.add( + TextSpan( + text: text.substring(hit, end), + style: baseStyle.merge(highlightStyle), + recognizer: recognizer, + ), + ); + cursor = end; + } + return spans; +} + +class HighlightedLinkify extends StatefulWidget { + final String text; + 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, + }); + + @override + State createState() => _HighlightedLinkifyState(); +} + +class _HighlightedLinkifyState extends State { + final List _recognizers = []; + + @override + void dispose() { + for (final r in _recognizers) { + r.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + for (final r in _recognizers) { + r.dispose(); + } + _recognizers.clear(); + + final defaultStyle = widget.style ?? + Theme.of(context).textTheme.bodyMedium ?? + DefaultTextStyle.of(context).style; + final linkStyle = (widget.linkStyle ?? + const TextStyle(color: Colors.blue, decoration: TextDecoration.underline)) + .merge(defaultStyle.copyWith(color: null, decoration: null)); + const linkHighlight = TextStyle( + backgroundColor: Color(0xFFFFD54F), + color: Colors.black, + decoration: TextDecoration.underline, + ); + + final elements = linkify_pkg.linkify(widget.text); + final spans = []; + + for (final el in elements) { + if (el is LinkableElement) { + final recognizer = TapGestureRecognizer() + ..onTap = () => widget.onOpen?.call(el); + _recognizers.add(recognizer); + spans.addAll( + buildHighlightedSpans( + text: el.text, + query: widget.highlight, + baseStyle: linkStyle, + highlightStyle: linkHighlight, + recognizer: recognizer, + ), + ); + } else { + spans.addAll( + buildHighlightedSpans( + text: el.text, + query: widget.highlight, + baseStyle: defaultStyle, + ), + ); + } + } + + return Text.rich(TextSpan(children: spans)); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 98ddb1e..34428e3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,7 +38,9 @@ dependencies: workmanager: ^0.9.0+3 intl: ^0.20.2 flutter_linkify: ^6.0.0 + linkify: ^5.0.0 flutter_local_notifications: ^21.0.0 + scrollable_positioned_list: ^0.3.8 flutter_split_view: ^0.1.2 flutter_svg: ^2.0.10 freezed_annotation: ^3.1.0 diff --git a/test/view/talk/chat_search_controller_test.dart b/test/view/talk/chat_search_controller_test.dart new file mode 100644 index 0000000..47dd0a7 --- /dev/null +++ b/test/view/talk/chat_search_controller_test.dart @@ -0,0 +1,163 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumcloud/talk/chat/get_chat_response.dart'; +import 'package:marianum_mobile/api/marianumcloud/talk/room/get_room_response.dart'; +import 'package:marianum_mobile/view/pages/talk/data/chat_search_controller.dart'; + +GetChatResponseObject _msg({ + required int id, + required int timestamp, + String actorDisplayName = 'Anyone', + String message = '', + String systemMessage = '', + GetRoomResponseObjectMessageType type = + GetRoomResponseObjectMessageType.comment, + Map? params, +}) => + GetChatResponseObject( + id, + 'token', + GetRoomResponseObjectMessageActorType.user, + 'actor-id', + actorDisplayName, + timestamp, + systemMessage, + type, + true, + '', + message, + params, + null, + null, + null, + ); + +GetChatResponse _response(List messages) => + GetChatResponse(messages.toSet()); + +void main() { + group('ChatSearchController.findMatches', () { + test('empty query returns no matches', () { + final response = _response([ + _msg(id: 1, timestamp: 100, message: 'Hallo'), + ]); + expect(ChatSearchController.findMatches(response, ''), isEmpty); + expect(ChatSearchController.findMatches(response, ' '), isEmpty); + }); + + test('matches message text case-insensitively', () { + final response = _response([ + _msg(id: 1, timestamp: 100, message: 'Hallo Welt'), + _msg(id: 2, timestamp: 200, message: 'nichts hier'), + ]); + final matches = ChatSearchController.findMatches(response, 'WELT'); + expect(matches.length, 1); + expect(matches.first.messageId, 1); + }); + + test('matches actor display name', () { + final response = _response([ + _msg( + id: 1, + timestamp: 100, + actorDisplayName: 'Lisa Maier', + message: 'irgendwas', + ), + _msg( + id: 2, + timestamp: 200, + actorDisplayName: 'Tom Weber', + message: 'auch was', + ), + ]); + final matches = ChatSearchController.findMatches(response, 'lisa'); + expect(matches.length, 1); + expect(matches.first.messageId, 1); + }); + + test('system messages match on text but not on actor', () { + final response = _response([ + _msg( + id: 1, + timestamp: 100, + actorDisplayName: 'Lisa', + message: 'Lisa ist beigetreten', + type: GetRoomResponseObjectMessageType.system, + ), + ]); + // Match on text content + expect( + ChatSearchController.findMatches(response, 'beigetreten').length, + 1, + ); + // Actor name alone (not in text) should not match for system messages + final actorOnlyResponse = _response([ + _msg( + id: 1, + timestamp: 100, + actorDisplayName: 'Lisa', + message: 'jemand ist beigetreten', + type: GetRoomResponseObjectMessageType.system, + ), + ]); + expect( + ChatSearchController.findMatches(actorOnlyResponse, 'lisa'), + isEmpty, + ); + }); + + test('reaction and poll_voted system messages are filtered out', () { + final response = _response([ + _msg( + id: 1, + timestamp: 100, + message: 'Treffer', + systemMessage: 'reaction', + type: GetRoomResponseObjectMessageType.system, + ), + _msg( + id: 2, + timestamp: 200, + message: 'Treffer', + systemMessage: 'poll_voted', + type: GetRoomResponseObjectMessageType.system, + ), + _msg(id: 3, timestamp: 300, message: 'Treffer'), + ]); + final matches = ChatSearchController.findMatches(response, 'Treffer'); + expect(matches.length, 1); + expect(matches.first.messageId, 3); + }); + + test('rich object parameters are searchable (e.g. file names)', () { + final response = _response([ + _msg( + id: 1, + timestamp: 100, + message: '{file}', + params: { + 'file': RichObjectString( + RichObjectStringObjectType.file, + '42', + 'hausaufgaben.pdf', + null, + null, + ), + }, + ), + ]); + final matches = ChatSearchController.findMatches(response, 'hausaufgab'); + expect(matches.length, 1); + expect(matches.first.messageId, 1); + }); + + test('matches are sorted newest first', () { + final response = _response([ + _msg(id: 1, timestamp: 100, message: 'X'), + _msg(id: 2, timestamp: 300, message: 'X'), + _msg(id: 3, timestamp: 200, message: 'X'), + ]); + final matches = ChatSearchController.findMatches(response, 'x'); + expect(matches.map((m) => m.messageId).toList(), [2, 3, 1]); + }); + }); +} From 7d02e70459b4ba512141ee660b05a1dab5b20e7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sat, 9 May 2026 22:23:25 +0200 Subject: [PATCH 02/11] implemented short relative date formatting for chat and added unit tests --- .../talk/chat/get_chat_response.dart | 2 +- lib/extensions/date_time.dart | 14 ++++++ test/extensions/date_time_test.dart | 49 +++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/lib/api/marianumcloud/talk/chat/get_chat_response.dart b/lib/api/marianumcloud/talk/chat/get_chat_response.dart index 9b54111..8cb3b4c 100644 --- a/lib/api/marianumcloud/talk/chat/get_chat_response.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_response.dart @@ -66,7 +66,7 @@ class GetChatResponseObject { static GetChatResponseObject getDateDummy(int timestamp) { var elementDate = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000); - return getTextDummy(elementDate.formatDate()); + return getTextDummy(elementDate.formatDateRelativeShort()); } static GetChatResponseObject getTextDummy(String text) => diff --git a/lib/extensions/date_time.dart b/lib/extensions/date_time.dart index 830f2b7..665019b 100644 --- a/lib/extensions/date_time.dart +++ b/lib/extensions/date_time.dart @@ -46,4 +46,18 @@ extension DateTimeFormatting on DateTime { String formatRelative() => Jiffy.parseFromDateTime(this).fromNow(); String timeRangeTo(DateTime end) => '${formatHm()} - ${end.formatHm()}'; + + String formatDateRelativeShort({DateTime? now}) { + final reference = now ?? DateTime.now(); + final today = DateTime(reference.year, reference.month, reference.day); + final self = DateTime(year, month, day); + final diff = today.difference(self).inDays; + + if (diff == 0) return 'Heute'; + if (diff == 1) return 'Gestern'; + if (diff > 1 && diff <= 6) { + return Jiffy.parseFromDateTime(this).format(pattern: 'EEEE'); + } + return formatDate(); + } } diff --git a/test/extensions/date_time_test.dart b/test/extensions/date_time_test.dart index 2f174a9..8d97940 100644 --- a/test/extensions/date_time_test.dart +++ b/test/extensions/date_time_test.dart @@ -58,4 +58,53 @@ void main() { expect(dt.timeRangeTo(end), '09:07 - 09:52'); }); }); + + group('formatDateRelativeShort', () { + final now = DateTime(2026, 5, 9, 14, 0); // Saturday + + test('today returns "Heute"', () { + expect( + DateTime(2026, 5, 9, 8, 0).formatDateRelativeShort(now: now), + 'Heute', + ); + }); + + test('yesterday returns "Gestern"', () { + expect( + DateTime(2026, 5, 8, 23, 30).formatDateRelativeShort(now: now), + 'Gestern', + ); + }); + + test('2 to 6 days ago returns the German weekday name', () { + // 2026-05-07 is a Thursday + expect( + DateTime(2026, 5, 7).formatDateRelativeShort(now: now), + 'Donnerstag', + ); + // 2026-05-03 is a Sunday (6 days before Saturday 9th) + expect( + DateTime(2026, 5, 3).formatDateRelativeShort(now: now), + 'Sonntag', + ); + }); + + test('7 days or more ago falls back to dd.MM.yyyy', () { + expect( + DateTime(2026, 5, 2).formatDateRelativeShort(now: now), + '02.05.2026', + ); + expect( + DateTime(2026, 1, 1).formatDateRelativeShort(now: now), + '01.01.2026', + ); + }); + + test('future dates fall back to dd.MM.yyyy', () { + expect( + DateTime(2026, 5, 10).formatDateRelativeShort(now: now), + '10.05.2026', + ); + }); + }); } From 79a6d9a5940b032781957c5cb5a0d5f556c0aa31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sat, 9 May 2026 22:28:26 +0200 Subject: [PATCH 03/11] filtered deleted messages from search and chat view, refactored chat bubble styling for deleted comments, and updated tests --- lib/view/pages/talk/chat_view.dart | 6 ++- .../talk/data/chat_search_controller.dart | 1 + lib/view/pages/talk/widgets/chat_bubble.dart | 39 +++++++++----- .../talk/chat_search_controller_test.dart | 54 +++++++++++-------- 4 files changed, 65 insertions(+), 35 deletions(-) diff --git a/lib/view/pages/talk/chat_view.dart b/lib/view/pages/talk/chat_view.dart index ccf3a07..ff92ecc 100644 --- a/lib/view/pages/talk/chat_view.dart +++ b/lib/view/pages/talk/chat_view.dart @@ -178,6 +178,7 @@ class _ChatViewState extends State { if (element.systemMessage.contains('reaction')) continue; if (element.systemMessage.contains('poll_voted')) continue; + if (element.systemMessage.contains('message_deleted')) continue; final commonRead = int.parse( response.headers?['x-chat-last-common-read'] ?? '0', ); @@ -209,7 +210,10 @@ class _ChatViewState extends State { context: context, isSender: element.actorId == widget.selfId && - element.messageType == GetRoomResponseObjectMessageType.comment, + (element.messageType == + GetRoomResponseObjectMessageType.comment || + element.messageType == + GetRoomResponseObjectMessageType.deletedComment), bubbleData: element, chatData: widget.room, refetch: ({bool renew = false}) => _refresh(), diff --git a/lib/view/pages/talk/data/chat_search_controller.dart b/lib/view/pages/talk/data/chat_search_controller.dart index 7d9e54b..6b9c891 100644 --- a/lib/view/pages/talk/data/chat_search_controller.dart +++ b/lib/view/pages/talk/data/chat_search_controller.dart @@ -21,6 +21,7 @@ class ChatSearchController { for (final element in response.sortByTimestamp()) { if (element.systemMessage.contains('reaction')) continue; if (element.systemMessage.contains('poll_voted')) continue; + if (element.systemMessage.contains('message_deleted')) continue; final haystackText = RichObjectStringProcessor.parseToString( element.message, diff --git a/lib/view/pages/talk/widgets/chat_bubble.dart b/lib/view/pages/talk/widgets/chat_bubble.dart index a100a20..fc184a2 100644 --- a/lib/view/pages/talk/widgets/chat_bubble.dart +++ b/lib/view/pages/talk/widgets/chat_bubble.dart @@ -148,11 +148,33 @@ class _ChatBubbleState extends State ).asDialog(context); } + bool get _rendersAsCommentBubble => + widget.bubbleData.messageType == + GetRoomResponseObjectMessageType.comment || + widget.bubbleData.messageType == + GetRoomResponseObjectMessageType.deletedComment; + + TextStyle? _messageTextStyle(BuildContext context) { + final theme = Theme.of(context); + switch (widget.bubbleData.messageType) { + case GetRoomResponseObjectMessageType.system: + return theme.textTheme.bodySmall; + case GetRoomResponseObjectMessageType.deletedComment: + return theme.textTheme.bodyMedium?.copyWith( + color: theme.hintColor, + fontStyle: FontStyle.italic, + ); + case GetRoomResponseObjectMessageType.comment: + case GetRoomResponseObjectMessageType.voiceMessage: + case GetRoomResponseObjectMessageType.command: + return null; + } + } + BubbleStyle _getStyle() { final styles = ChatBubbleStyles(context); final BubbleStyle base; - if (widget.bubbleData.messageType != - GetRoomResponseObjectMessageType.comment) { + if (!_rendersAsCommentBubble) { base = styles.getSystemStyle(); } else { base = widget.isSender @@ -210,14 +232,11 @@ class _ChatBubbleState extends State originalData: widget.bubbleData.messageParameters, ); final showActorDisplayName = - widget.bubbleData.messageType == - GetRoomResponseObjectMessageType.comment && + _rendersAsCommentBubble && widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne; final showBubbleTime = widget.bubbleData.messageType != - GetRoomResponseObjectMessageType.system && - widget.bubbleData.messageType != - GetRoomResponseObjectMessageType.deletedComment; + GetRoomResponseObjectMessageType.system; final parent = widget.bubbleData.parent; final actorBaseStyle = TextStyle( @@ -294,11 +313,7 @@ class _ChatBubbleState extends State timeText: timeText, messageWidget: message.getWidget( highlightQuery: widget.highlightQuery, - style: - widget.bubbleData.messageType == - GetRoomResponseObjectMessageType.system - ? Theme.of(context).textTheme.bodySmall - : null, + style: _messageTextStyle(context), ), parent: parent, bubbleData: widget.bubbleData, diff --git a/test/view/talk/chat_search_controller_test.dart b/test/view/talk/chat_search_controller_test.dart index 47dd0a7..6d9fa37 100644 --- a/test/view/talk/chat_search_controller_test.dart +++ b/test/view/talk/chat_search_controller_test.dart @@ -105,28 +105,38 @@ void main() { ); }); - test('reaction and poll_voted system messages are filtered out', () { - final response = _response([ - _msg( - id: 1, - timestamp: 100, - message: 'Treffer', - systemMessage: 'reaction', - type: GetRoomResponseObjectMessageType.system, - ), - _msg( - id: 2, - timestamp: 200, - message: 'Treffer', - systemMessage: 'poll_voted', - type: GetRoomResponseObjectMessageType.system, - ), - _msg(id: 3, timestamp: 300, message: 'Treffer'), - ]); - final matches = ChatSearchController.findMatches(response, 'Treffer'); - expect(matches.length, 1); - expect(matches.first.messageId, 3); - }); + test( + 'reaction, poll_voted and message_deleted system messages are filtered out', + () { + final response = _response([ + _msg( + id: 1, + timestamp: 100, + message: 'Treffer', + systemMessage: 'reaction', + type: GetRoomResponseObjectMessageType.system, + ), + _msg( + id: 2, + timestamp: 200, + message: 'Treffer', + systemMessage: 'poll_voted', + type: GetRoomResponseObjectMessageType.system, + ), + _msg( + id: 4, + timestamp: 250, + message: 'Treffer', + systemMessage: 'message_deleted', + type: GetRoomResponseObjectMessageType.system, + ), + _msg(id: 3, timestamp: 300, message: 'Treffer'), + ]); + final matches = ChatSearchController.findMatches(response, 'Treffer'); + expect(matches.length, 1); + expect(matches.first.messageId, 3); + }, + ); test('rich object parameters are searchable (e.g. file names)', () { final response = _response([ From 9accb488f23f683c358adfbc6cc9d496ef48fc5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sat, 9 May 2026 22:32:45 +0200 Subject: [PATCH 04/11] added delete confirmation dialog for chat messages and refined deletion logic flow --- .../widgets/chat_message_options_dialog.dart | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart index 1435914..c7e0fea 100644 --- a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart +++ b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart @@ -140,12 +140,22 @@ void showChatMessageOptionsDialog( }, ), if (canDelete) - AsyncListTile( + ListTile( leading: const Icon(Icons.delete_outline), title: const Text('Nachricht löschen'), - onPressed: () async { - await DeleteMessage(chatData.token, bubbleData.id).run(); - if (sheetCtx.mounted) sheetCtx.read().refresh(); + onTap: () { + ConfirmDialog( + title: 'Nachricht löschen?', + content: 'Die Nachricht wird für alle Teilnehmer gelöscht.', + confirmButton: 'Löschen', + onConfirmAsync: () async { + await DeleteMessage(chatData.token, bubbleData.id).run(); + if (!sheetCtx.mounted) return; + final bloc = sheetCtx.read(); + Navigator.of(sheetCtx).pop(); + bloc.refresh(); + }, + ).asDialog(sheetCtx); }, ), DebugTile(sheetCtx).jsonData(bubbleData.toJson()), From 8e6b1877ccc0fd58123e02523f9a19aaa8062fa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sat, 9 May 2026 22:35:20 +0200 Subject: [PATCH 05/11] implemented search for marianum messages with name and date filtering --- .../marianum_message_list_view.dart | 21 +++++- .../search_marianum_messages.dart | 66 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 lib/view/pages/marianum_message/search_marianum_messages.dart diff --git a/lib/view/pages/marianum_message/marianum_message_list_view.dart b/lib/view/pages/marianum_message/marianum_message_list_view.dart index f49b2a4..733db5d 100644 --- a/lib/view/pages/marianum_message/marianum_message_list_view.dart +++ b/lib/view/pages/marianum_message/marianum_message_list_view.dart @@ -6,6 +6,7 @@ import '../../../state/app/infrastructure/loadable_state/view/loadable_state_con import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; import '../../../state/app/modules/marianum_message/bloc/marianum_message_bloc.dart'; import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart'; +import 'search_marianum_messages.dart'; class MarianumMessageListView extends StatelessWidget { const MarianumMessageListView({super.key}); @@ -16,7 +17,25 @@ class MarianumMessageListView extends StatelessWidget { ) => BlocModule>( create: (context) => MarianumMessageBloc(), child: (context, bloc, state) => Scaffold( - appBar: AppBar(title: const Text('Marianum Message')), + appBar: AppBar( + title: const Text('Marianum Message'), + actions: [ + IconButton( + icon: const Icon(Icons.search), + onPressed: () { + final list = bloc.state.data?.messageList; + if (list == null) return; + showSearch( + context: context, + delegate: SearchMarianumMessages( + base: list.base, + messages: list.messages, + ), + ); + }, + ), + ], + ), body: LoadableStateConsumer( child: (state, loading) => ListView.builder( itemCount: state.messageList.messages.length, diff --git a/lib/view/pages/marianum_message/search_marianum_messages.dart b/lib/view/pages/marianum_message/search_marianum_messages.dart new file mode 100644 index 0000000..effba1c --- /dev/null +++ b/lib/view/pages/marianum_message/search_marianum_messages.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import '../../../routing/app_routes.dart'; +import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart'; +import '../../../widget/placeholder_view.dart'; + +class SearchMarianumMessages extends SearchDelegate { + final String base; + final List messages; + + SearchMarianumMessages({required this.base, required this.messages}); + + List _matches() { + final q = query.trim().toLowerCase(); + if (q.isEmpty) return messages; + return messages.where((m) { + return m.name.toLowerCase().contains(q) || + m.date.toLowerCase().contains(q); + }).toList(); + } + + @override + List? buildActions(BuildContext context) => [ + if (query.isNotEmpty) + IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)), + ]; + + @override + Widget? buildLeading(BuildContext context) => IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => close(context, null), + ); + + @override + Widget buildResults(BuildContext context) { + final matches = _matches(); + if (matches.isEmpty) { + return const PlaceholderView( + icon: Icons.search_off_outlined, + text: 'Keine Treffer', + ); + } + return ListView.builder( + itemCount: matches.length, + itemBuilder: (_, i) { + final message = matches[i]; + return ListTile( + leading: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [Icon(Icons.newspaper)], + ), + title: Text(message.name, overflow: TextOverflow.ellipsis), + subtitle: Text('vom ${message.date}'), + trailing: const Icon(Icons.arrow_right), + onTap: () { + close(context, message); + AppRoutes.openMarianumMessage(context, base, message); + }, + ); + }, + ); + } + + @override + Widget buildSuggestions(BuildContext context) => buildResults(context); +} From 14090b96f4e920693631d96474b22763c4fdcde8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sat, 9 May 2026 23:20:11 +0200 Subject: [PATCH 06/11] implemented file search with local cache and server-side support, added result highlighting, and integrated search delegate into files page --- .../marianumcloud/search/search_files.dart | 36 ++++ .../search/search_files_response.dart | 91 +++++++++ .../search/search_files_response.g.dart | 44 ++++ lib/view/pages/files/files.dart | 10 + .../files/search/files_search_controller.dart | 146 +++++++++++++ .../files/search/files_search_delegate.dart | 56 +++++ .../files/search/files_search_results.dart | 193 ++++++++++++++++++ .../files/search/local_cache_search.dart | 65 ++++++ .../pages/files/widgets/file_element.dart | 56 ++++- test/view/files/local_cache_search_test.dart | 77 +++++++ 10 files changed, 767 insertions(+), 7 deletions(-) create mode 100644 lib/api/marianumcloud/search/search_files.dart create mode 100644 lib/api/marianumcloud/search/search_files_response.dart create mode 100644 lib/api/marianumcloud/search/search_files_response.g.dart create mode 100644 lib/view/pages/files/search/files_search_controller.dart create mode 100644 lib/view/pages/files/search/files_search_delegate.dart create mode 100644 lib/view/pages/files/search/files_search_results.dart create mode 100644 lib/view/pages/files/search/local_cache_search.dart create mode 100644 test/view/files/local_cache_search_test.dart diff --git a/lib/api/marianumcloud/search/search_files.dart b/lib/api/marianumcloud/search/search_files.dart new file mode 100644 index 0000000..0d2c382 --- /dev/null +++ b/lib/api/marianumcloud/search/search_files.dart @@ -0,0 +1,36 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + +import '../nextcloud_ocs.dart'; +import 'search_files_response.dart'; + +/// Wraps the Nextcloud OCS Search Provider API for the `files` provider. +/// Endpoint: `/ocs/v2.php/search/providers/files/search`. +class SearchFiles { + Future run({ + required String term, + int limit = 50, + int? cursor, + }) async { + final endpoint = NextcloudOcs.uri( + 'search/providers/files/search', + queryParameters: { + 'term': term, + 'limit': limit.toString(), + if (cursor != null) 'cursor': cursor.toString(), + }, + ); + final response = await http.get(endpoint, headers: NextcloudOcs.headers()); + if (response.statusCode != HttpStatus.ok) { + throw Exception( + 'Files search failed with ${response.statusCode}: ${response.body}', + ); + } + final decoded = jsonDecode(response.body) as Map; + final ocs = decoded['ocs'] as Map; + final data = ocs['data'] as Map; + return SearchFilesResponse.fromJson(data); + } +} diff --git a/lib/api/marianumcloud/search/search_files_response.dart b/lib/api/marianumcloud/search/search_files_response.dart new file mode 100644 index 0000000..d9b3d72 --- /dev/null +++ b/lib/api/marianumcloud/search/search_files_response.dart @@ -0,0 +1,91 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../webdav/queries/list_files/cacheable_file.dart'; + +part 'search_files_response.g.dart'; + +/// Subset of the OCS Search Provider API response we actually consume. +/// The provider (`files`) returns one object per match plus pagination state. +@JsonSerializable(explicitToJson: true) +class SearchFilesResponse { + final String name; + final bool isPaginated; + final int? cursor; + final List entries; + + SearchFilesResponse({ + required this.name, + required this.isPaginated, + required this.cursor, + required this.entries, + }); + + factory SearchFilesResponse.fromJson(Map json) => + _$SearchFilesResponseFromJson(json); + Map toJson() => _$SearchFilesResponseToJson(this); +} + +@JsonSerializable() +class SearchFilesEntry { + final String title; + final String? subline; + final String? icon; + final String? resourceUrl; + final Map? attributes; + + SearchFilesEntry({ + required this.title, + this.subline, + this.icon, + this.resourceUrl, + this.attributes, + }); + + factory SearchFilesEntry.fromJson(Map json) => + _$SearchFilesEntryFromJson(json); + Map toJson() => _$SearchFilesEntryToJson(this); + + /// Heuristic — the files provider sets icon classes containing "folder" for + /// directories. Falls back to false when missing or unrecognised. + bool get isDirectory => (icon ?? '').toLowerCase().contains('folder'); + + String? _stringAttribute(String key) { + final raw = attributes?[key]; + return raw is String && raw.isNotEmpty ? raw : null; + } + + String? _dirFromResourceUrl() { + final url = resourceUrl; + if (url == null) return null; + return Uri.tryParse(url)?.queryParameters['dir']; + } + + /// Reconstructs the WebDAV-relative path used elsewhere (matching + /// [CacheableFile.path] — no leading slash, trailing slash for + /// directories). Prefers the explicit `path` attribute set by Nextcloud's + /// files search provider (28+); falls back to the `dir` query parameter + /// in [resourceUrl]. Returns `null` when neither is available — `subline` + /// is intentionally **not** parsed because it is localized UI text + /// ("in {folder}"), not a path, and using it produced bogus duplicate + /// folder headers like "/in Alte-Notebooks". + String? get webdavPath { + final attrPath = _stringAttribute('path'); + if (attrPath != null) { + final stripped = attrPath.replaceAll(RegExp(r'^/+|/+$'), ''); + return isDirectory ? '$stripped/' : stripped; + } + final dir = _dirFromResourceUrl(); + if (dir != null) { + final stripped = dir.replaceAll(RegExp(r'^/+|/+$'), ''); + final base = stripped.isEmpty ? title : '$stripped/$title'; + return isDirectory ? '$base/' : base; + } + return null; + } + + CacheableFile? toCacheable() { + final path = webdavPath; + if (path == null) return null; + return CacheableFile(path: path, isDirectory: isDirectory, name: title); + } +} diff --git a/lib/api/marianumcloud/search/search_files_response.g.dart b/lib/api/marianumcloud/search/search_files_response.g.dart new file mode 100644 index 0000000..64dbe45 --- /dev/null +++ b/lib/api/marianumcloud/search/search_files_response.g.dart @@ -0,0 +1,44 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'search_files_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SearchFilesResponse _$SearchFilesResponseFromJson(Map json) => + SearchFilesResponse( + name: json['name'] as String, + isPaginated: json['isPaginated'] as bool, + cursor: (json['cursor'] as num?)?.toInt(), + entries: (json['entries'] as List) + .map((e) => SearchFilesEntry.fromJson(e as Map)) + .toList(), + ); + +Map _$SearchFilesResponseToJson( + SearchFilesResponse instance, +) => { + 'name': instance.name, + 'isPaginated': instance.isPaginated, + 'cursor': instance.cursor, + 'entries': instance.entries.map((e) => e.toJson()).toList(), +}; + +SearchFilesEntry _$SearchFilesEntryFromJson(Map json) => + SearchFilesEntry( + title: json['title'] as String, + subline: json['subline'] as String?, + icon: json['icon'] as String?, + resourceUrl: json['resourceUrl'] as String?, + attributes: json['attributes'] as Map?, + ); + +Map _$SearchFilesEntryToJson(SearchFilesEntry instance) => + { + 'title': instance.title, + 'subline': instance.subline, + 'icon': instance.icon, + 'resourceUrl': instance.resourceUrl, + 'attributes': instance.attributes, + }; diff --git a/lib/view/pages/files/files.dart b/lib/view/pages/files/files.dart index b860b7e..54943d2 100644 --- a/lib/view/pages/files/files.dart +++ b/lib/view/pages/files/files.dart @@ -14,6 +14,7 @@ import '../../../utils/cache_invalidation_bus.dart'; import '../../../widget/placeholder_view.dart'; import 'data/sort_options.dart'; import 'files_upload_dialog.dart'; +import 'search/files_search_delegate.dart'; import 'widgets/add_file_menu.dart'; import 'widgets/clipboard_banner.dart'; import 'widgets/file_element.dart'; @@ -101,6 +102,15 @@ class _FilesViewState extends State<_FilesView> { appBar: AppBar( title: Text(widget.path.isNotEmpty ? widget.path.last : 'Dateien'), actions: [ + IconButton( + tooltip: 'Suchen', + icon: const Icon(Icons.search), + onPressed: () async { + final delegate = FilesSearchDelegate(pathScope: widget.path); + await showSearch(context: context, delegate: delegate); + delegate.disposeController(); + }, + ), FilesSortActions( currentSort: currentSort, ascending: currentSortDirection, diff --git a/lib/view/pages/files/search/files_search_controller.dart b/lib/view/pages/files/search/files_search_controller.dart new file mode 100644 index 0000000..ed2fee3 --- /dev/null +++ b/lib/view/pages/files/search/files_search_controller.dart @@ -0,0 +1,146 @@ +import 'package:flutter/foundation.dart'; + +import '../../../../api/marianumcloud/search/search_files.dart'; +import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; +import '../../../../utils/debouncer.dart'; +import 'local_cache_search.dart'; + +/// Holds the live state of a Files-search session: current query, the latest +/// local-cache hits (synchronous), the latest server hits (asynchronous, +/// debounced), and loading/error flags. Notifies listeners whenever any of +/// these change so the UI can rebuild incrementally as results stream in. +class FilesSearchController extends ChangeNotifier { + FilesSearchController({List? initialPathScope}) + : _pathScope = List.from(initialPathScope ?? const []); + + static const Duration _serverDebounce = Duration(seconds: 1); + final String _debounceTag = + 'files-search-${DateTime.now().microsecondsSinceEpoch}'; + final SearchFiles _api = SearchFiles(); + + String _query = ''; + List _pathScope; + List _cacheResults = const []; + List _serverResults = const []; + bool _serverLoading = false; + Object? _serverError; + int _serverEpoch = 0; + + String get query => _query; + List get pathScope => List.unmodifiable(_pathScope); + bool get isScoped => _pathScope.isNotEmpty; + List get cacheResults => _cacheResults; + List get serverResults => _serverResults; + bool get serverLoading => _serverLoading; + Object? get serverError => _serverError; + + /// Combined, deduplicated result list (cache hits first, then any + /// server-only hits) — handy for empty-state checks. Dedup key is the + /// WebDAV path. + List get combinedResults { + if (_cacheResults.isEmpty) return _serverResults; + if (_serverResults.isEmpty) return _cacheResults; + final seen = {for (final f in _cacheResults) f.path}; + return [ + ..._cacheResults, + ..._serverResults.where((f) => seen.add(f.path)), + ]; + } + + Future setQuery(String value) async { + if (value == _query) return; + _query = value; + // Bumping the epoch up front invalidates any in-flight server call from + // a previous query, so its late response cannot toggle `_serverLoading` + // off while a fresh search is queued behind the debounce. + final epoch = ++_serverEpoch; + if (_query.trim().isEmpty) { + Debouncer.cancel(_debounceTag); + _cacheResults = const []; + _serverResults = const []; + _serverLoading = false; + _serverError = null; + notifyListeners(); + return; + } + // Show loading immediately — even before the (typically fast) cache + // scan resolves — so the indicator is visible the moment the user + // starts typing rather than after the first await hop. + _serverLoading = true; + _serverError = null; + notifyListeners(); + + final cacheHits = await searchLocalCaches(_query, pathScope: _pathScope); + if (epoch != _serverEpoch) return; + _cacheResults = cacheHits; + notifyListeners(); + _scheduleServerCall(); + } + + /// Drops the path filter and re-runs the current search globally. Used by + /// the empty-state "Im Hauptverzeichnis suchen" button. + Future searchEverywhere() async { + if (!isScoped) return; + _pathScope = const []; + final epoch = ++_serverEpoch; + if (_query.trim().isEmpty) { + notifyListeners(); + return; + } + _serverLoading = true; + _serverError = null; + notifyListeners(); + + final cacheHits = await searchLocalCaches(_query); + if (epoch != _serverEpoch) return; + _cacheResults = cacheHits; + notifyListeners(); + _scheduleServerCall(); + } + + /// Re-runs the current server query immediately, bypassing the debounce. + /// Wired to the `LoadableStateErrorScreen` "Erneut versuchen" button. + void retry() { + if (_query.trim().isEmpty) return; + ++_serverEpoch; + Debouncer.cancel(_debounceTag); + _serverLoading = true; + _serverError = null; + notifyListeners(); + _runServerCall(); + } + + void _scheduleServerCall() { + Debouncer.debounce(_debounceTag, _serverDebounce, _runServerCall); + } + + Future _runServerCall() async { + final epoch = _serverEpoch; + final term = _query; + final scopePrefix = _pathScope.isEmpty ? '' : '${_pathScope.join('/')}/'; + try { + final response = await _api.run(term: term); + if (epoch != _serverEpoch) return; + _serverResults = response.entries + .map((e) => e.toCacheable()) + .whereType() + .where((f) => scopePrefix.isEmpty || f.path.startsWith(scopePrefix)) + .toList(); + _serverLoading = false; + _serverError = null; + notifyListeners(); + } on Object catch (e) { + if (epoch != _serverEpoch) return; + _serverResults = const []; + _serverLoading = false; + _serverError = e; + notifyListeners(); + } + } + + @override + void dispose() { + Debouncer.cancel(_debounceTag); + super.dispose(); + } +} diff --git a/lib/view/pages/files/search/files_search_delegate.dart b/lib/view/pages/files/search/files_search_delegate.dart new file mode 100644 index 0000000..c909a7b --- /dev/null +++ b/lib/view/pages/files/search/files_search_delegate.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +import 'files_search_controller.dart'; +import 'files_search_results.dart'; + +/// Material `SearchDelegate` for the Files module — opens via the magnifier +/// in `FilesPage`'s AppBar (mirroring `SearchMarianumMessages`). Owns one +/// [FilesSearchController]; cache + server hits stream into the result list +/// as the user types. +class FilesSearchDelegate extends SearchDelegate { + final FilesSearchController _controller; + + FilesSearchDelegate({required List pathScope}) + : _controller = FilesSearchController(initialPathScope: pathScope), + super(searchFieldLabel: 'Dateien suchen'); + + /// Must be called by the host widget after `showSearch` returns so the + /// controller's listeners and pending debounce timers are released. + void disposeController() => _controller.dispose(); + + @override + List? buildActions(BuildContext context) => [ + if (query.isNotEmpty) + IconButton( + tooltip: 'Suche leeren', + icon: const Icon(Icons.clear), + onPressed: () { + query = ''; + }, + ), + ]; + + @override + Widget? buildLeading(BuildContext context) => IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => close(context, null), + ); + + @override + Widget buildResults(BuildContext context) { + _controller.setQuery(query); + return FilesSearchResults( + controller: _controller, + onResultTap: () => close(context, null), + ); + } + + @override + Widget buildSuggestions(BuildContext context) { + _controller.setQuery(query); + return FilesSearchResults( + controller: _controller, + onResultTap: () => close(context, null), + ); + } +} diff --git a/lib/view/pages/files/search/files_search_results.dart b/lib/view/pages/files/search/files_search_results.dart new file mode 100644 index 0000000..ff9b611 --- /dev/null +++ b/lib/view/pages/files/search/files_search_results.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; + +import '../../../../api/errors/error_mapper.dart'; +import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; +import '../../../../routing/app_routes.dart'; +import '../../../../state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart'; +import '../../../../state/app/infrastructure/loadable_state/bloc/loadable_state_state.dart'; +import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_background_loading.dart'; +import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart'; +import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_error_screen.dart'; +import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart'; +import '../../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; +import '../../../../widget/placeholder_view.dart'; +import '../widgets/file_element.dart'; +import 'files_search_controller.dart'; + +/// Renders the live state of a [FilesSearchController]. Wraps everything in a +/// `LoadableStateBloc` module so the search reuses the standard primary / +/// background loading and error views from the rest of the app. +class FilesSearchResults extends StatelessWidget { + final FilesSearchController controller; + final VoidCallback? onResultTap; + + const FilesSearchResults({ + required this.controller, + this.onResultTap, + super.key, + }); + + @override + Widget build(BuildContext context) => + BlocModule( + create: (_) => LoadableStateBloc(), + child: (context, bloc, _) { + bloc.reFetch = controller.retry; + return ListenableBuilder( + listenable: controller, + builder: (context, _) => _buildBody(context), + ); + }, + ); + + Widget _buildBody(BuildContext context) { + if (controller.query.trim().isEmpty) { + return const PlaceholderView( + icon: Icons.search, + text: 'Tippen, um in Dateien zu suchen.', + ); + } + final combined = controller.combinedResults; + final hasContent = combined.isNotEmpty; + final hasError = controller.serverError != null; + final isLoading = controller.serverLoading; + + final showPrimaryLoading = isLoading && !hasContent; + final showBackgroundLoading = isLoading && hasContent; + final showErrorScreen = hasError && !hasContent && !isLoading; + final showErrorBar = hasError && hasContent; + final showEmpty = !hasContent && !hasError && !isLoading; + + final errorMessage = hasError ? errorToUserMessage(controller.serverError) : null; + + return Column( + children: [ + LoadableStateErrorBar( + visible: showErrorBar, + hasContent: hasContent, + message: errorMessage, + ), + // Background loading sits *outside* the result Stack so the linear + // progress bar is not painted over by the opaque ListView/ListTiles + // when cache hits are already on screen and the server is still + // working. The widget collapses to zero height when invisible. + LoadableStateBackgroundLoading(visible: showBackgroundLoading), + Expanded( + child: Stack( + children: [ + LoadableStatePrimaryLoading(visible: showPrimaryLoading), + LoadableStateErrorScreen( + visible: showErrorScreen, + message: errorMessage, + ), + if (showEmpty) _emptyState(context), + if (hasContent) _resultList(context, combined), + ], + ), + ), + ], + ); + } + + Widget _emptyState(BuildContext context) => PlaceholderView( + icon: Icons.search_off_outlined, + text: 'Keine Treffer gefunden.', + button: controller.isScoped + ? FilledButton.icon( + onPressed: controller.searchEverywhere, + icon: const Icon(Icons.travel_explore), + label: const Text('Im Hauptverzeichnis suchen'), + ) + : null, + ); + + Widget _resultList(BuildContext context, List combined) { + final groups = _groupByParent(combined); + final orderedKeys = groups.keys.toList()..sort(); + final items = []; + for (final folder in orderedKeys) { + final segments = _segmentsOf(folder); + items.add( + _FolderHeader( + folder: folder, + onOpen: () { + onResultTap?.call(); + AppRoutes.openFolder(context, segments); + }, + ), + ); + for (final file in groups[folder]!) { + items.add( + FileElement( + file, + segments, + controller.retry, + highlight: controller.query, + ), + ); + } + } + return ListView(padding: EdgeInsets.zero, children: items); + } + + Map> _groupByParent(List files) { + final map = >{}; + for (final file in files) { + map.putIfAbsent(_parentOf(file), () => []).add(file); + } + return map; + } + + String _parentOf(CacheableFile file) { + final stripped = file.path.replaceAll(RegExp(r'^/+|/+$'), ''); + final segments = stripped.split('/'); + if (segments.length <= 1) return '/'; + segments.removeLast(); + return '/${segments.join('/')}'; + } + + List _segmentsOf(String folder) { + final stripped = folder.replaceAll(RegExp(r'^/+|/+$'), ''); + if (stripped.isEmpty) return const []; + return stripped.split('/'); + } +} + +class _FolderHeader extends StatelessWidget { + final String folder; + final VoidCallback onOpen; + const _FolderHeader({required this.folder, required this.onOpen}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + width: double.infinity, + height: 38, + color: theme.colorScheme.surfaceContainer, + padding: const EdgeInsets.only(left: 16), + child: Row( + children: [ + Expanded( + child: Text( + folder, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + ), + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + tooltip: 'Ordner öffnen', + iconSize: 20, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.folder_open_outlined), + onPressed: onOpen, + ), + ], + ), + ); + } +} diff --git a/lib/view/pages/files/search/local_cache_search.dart b/lib/view/pages/files/search/local_cache_search.dart new file mode 100644 index 0000000..45a14e4 --- /dev/null +++ b/lib/view/pages/files/search/local_cache_search.dart @@ -0,0 +1,65 @@ +import 'dart:convert'; + +import 'package:localstore/localstore.dart'; + +import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; +import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; +import '../../../../api/request_cache.dart'; + +/// Document key prefix used by `ListFilesCache._documentId`. +const String _folderCachePrefix = 'wd-folder-'; + +/// Scans every cached folder listing in Localstore and returns files/folders +/// whose name contains [query] (case-insensitive). +/// +/// [pathScope] restricts results to entries whose WebDAV path starts with +/// the given folder. Pass an empty list (or null) to search globally. +/// +/// [docs] is an injection seam for tests — production callers leave it null +/// so the helper reads from the real Localstore. +Future> searchLocalCaches( + String query, { + List? pathScope, + Map? docs, +}) async { + final trimmed = query.trim(); + if (trimmed.isEmpty) return const []; + final needle = trimmed.toLowerCase(); + final scopePrefix = pathScope == null || pathScope.isEmpty + ? '' + : '${pathScope.join('/')}/'; + + final raw = + docs ?? + await Localstore.instance.collection(RequestCache.collection).get(); + if (raw == null || raw.isEmpty) return const []; + + final results = {}; + for (final entry in raw.entries) { + final docKey = entry.key.split('/').last; + if (!docKey.startsWith(_folderCachePrefix)) continue; + + final value = entry.value; + if (value is! Map) continue; + final json = value['json']; + if (json is! String) continue; + + final ListFilesResponse listing; + try { + listing = ListFilesResponse.fromJson( + jsonDecode(json) as Map, + ); + } on Object { + continue; + } + + for (final file in listing.files) { + if (!file.name.toLowerCase().contains(needle)) continue; + if (scopePrefix.isNotEmpty && !file.path.startsWith(scopePrefix)) { + continue; + } + results[file.path] ??= file; + } + } + return results.values.toList(); +} diff --git a/lib/view/pages/files/widgets/file_element.dart b/lib/view/pages/files/widgets/file_element.dart index c573ae8..da3f107 100644 --- a/lib/view/pages/files/widgets/file_element.dart +++ b/lib/view/pages/files/widgets/file_element.dart @@ -14,13 +14,25 @@ import '../../../../widget/centered_leading.dart'; import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/info_dialog.dart'; +import '../../talk/widgets/highlighted_linkify.dart'; import 'file_details_sheet.dart'; class FileElement extends StatefulWidget { final CacheableFile file; final List path; final void Function() refetch; - const FileElement(this.file, this.path, this.refetch, {super.key}); + + /// When non-null, occurrences of this string in the file name are visually + /// highlighted in the tile title. Used by the Files search delegate. + final String? highlight; + + const FileElement( + this.file, + this.path, + this.refetch, { + this.highlight, + super.key, + }); @override State createState() => _FileElementState(); @@ -118,7 +130,7 @@ class _FileElementState extends State { ); } - Widget _subtitle() { + Widget? _subtitle() { final status = _job?.status.value; if (status is DownloadInProgress) { return Row( @@ -135,10 +147,16 @@ class _FileElementState extends State { ], ); } - final modified = widget.file.modifiedAt ?? DateTime.now(); - return widget.file.isDirectory - ? Text('geändert ${modified.formatRelative()}') - : Text('${filesize(widget.file.size)}, ${modified.formatRelative()}'); + final modified = widget.file.modifiedAt; + final size = widget.file.size; + if (widget.file.isDirectory) { + if (modified == null) return null; + return Text('geändert ${modified.formatRelative()}'); + } + if (size == null && modified == null) return null; + if (size == null) return Text(modified!.formatRelative()); + if (modified == null) return Text(filesize(size)); + return Text('${filesize(size)}, ${modified.formatRelative()}'); } void _onTap() { @@ -328,12 +346,36 @@ class _FileElementState extends State { ); } + Widget _title(BuildContext context) { + final base = + Theme.of(context).textTheme.bodyLarge ?? + DefaultTextStyle.of(context).style; + if (widget.highlight == null || widget.highlight!.trim().isEmpty) { + return Text( + widget.file.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ); + } + return Text.rich( + TextSpan( + children: buildHighlightedSpans( + text: widget.file.name, + query: widget.highlight, + baseStyle: base, + ), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ); + } + @override Widget build(BuildContext context) => ListTile( leading: CenteredLeading( Icon(widget.file.isDirectory ? Icons.folder : Icons.description_outlined), ), - title: Text(widget.file.name, maxLines: 2, overflow: TextOverflow.ellipsis), + title: _title(context), subtitle: _subtitle(), trailing: Icon(widget.file.isDirectory ? Icons.arrow_right : null), onTap: _onTap, diff --git a/test/view/files/local_cache_search_test.dart b/test/view/files/local_cache_search_test.dart new file mode 100644 index 0000000..c1a0868 --- /dev/null +++ b/test/view/files/local_cache_search_test.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; +import 'package:marianum_mobile/api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; +import 'package:marianum_mobile/view/pages/files/search/local_cache_search.dart'; + +CacheableFile _file({ + required String path, + required String name, + bool isDirectory = false, +}) => CacheableFile(path: path, isDirectory: isDirectory, name: name); + +Map _doc(ListFilesResponse listing) => { + 'json': jsonEncode(listing.toJson()), + 'lastupdate': 0, +}; + +void main() { + group('searchLocalCaches', () { + final root = ListFilesResponse({ + _file(path: 'Documents/', name: 'Documents', isDirectory: true), + _file(path: 'Photos/', name: 'Photos', isDirectory: true), + _file(path: 'Reports.pdf', name: 'Reports.pdf'), + }); + final documents = ListFilesResponse({ + _file(path: 'Documents/Tax-Report.pdf', name: 'Tax-Report.pdf'), + _file(path: 'Documents/Notes.txt', name: 'Notes.txt'), + }); + final docs = { + '/MarianumMobile/wd-folder-aaa': _doc(root), + '/MarianumMobile/wd-folder-bbb': _doc(documents), + '/MarianumMobile/get-room-ccc': {'json': '{}', 'lastupdate': 0}, + }; + + test('matches by name case-insensitively across all caches', () async { + final hits = await searchLocalCaches('report', docs: docs); + final paths = hits.map((f) => f.path).toSet(); + expect(paths, {'Reports.pdf', 'Documents/Tax-Report.pdf'}); + }); + + test('returns empty list for empty query', () async { + expect(await searchLocalCaches(' ', docs: docs), isEmpty); + }); + + test('respects pathScope prefix', () async { + final hits = await searchLocalCaches( + 'report', + pathScope: ['Documents'], + docs: docs, + ); + expect(hits.map((f) => f.path), ['Documents/Tax-Report.pdf']); + }); + + test('ignores non-folder cache documents', () async { + final hits = await searchLocalCaches('anything', docs: docs); + // Only documents starting with `wd-folder-` are scanned. The unrelated + // `get-room-ccc` doc must not crash the helper. + expect(hits, isEmpty); + }); + + test('deduplicates entries that appear in multiple cached folders', + () async { + final shared = _file( + path: 'Documents/Tax-Report.pdf', + name: 'Tax-Report.pdf', + ); + final dedupRoot = ListFilesResponse({shared}); + final dedupDocs = { + '/MarianumMobile/wd-folder-aaa': _doc(dedupRoot), + '/MarianumMobile/wd-folder-bbb': _doc(dedupRoot), + }; + final hits = await searchLocalCaches('tax', docs: dedupDocs); + expect(hits, hasLength(1)); + }); + }); +} From bf28a678c92131eeddd6c62714599018bb39caf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sat, 9 May 2026 23:39:06 +0200 Subject: [PATCH 07/11] implemented background prefetching for files root, added 24-hour caching for root directory listing, and enabled cache renewal for manual refreshes --- .../queries/list_files/list_files_cache.dart | 41 ++++++++++++++++++- lib/main.dart | 15 +++++++ .../app/modules/files/bloc/files_bloc.dart | 7 +++- .../data_provider/files_data_provider.dart | 7 ++++ 4 files changed, 67 insertions(+), 3 deletions(-) diff --git a/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart index b9a4cd1..878f6a2 100644 --- a/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart @@ -16,8 +16,9 @@ class ListFilesCache extends SimpleCache { super.onNetworkData, super.onError, required String path, + super.renew = false, }) : super( - cacheTime: RequestCache.cacheNothing, + cacheTime: _cacheTimeFor(path), loader: () => ListFiles(ListFilesParams(path)).run(), fromJson: ListFilesResponse.fromJson, onUpdate: onUpdate, @@ -25,6 +26,44 @@ class ListFilesCache extends SimpleCache { start(_documentId(path)); } + /// The Nextcloud root listing is significantly slower than subfolders on + /// our instance and frequently returns HTTP 500. Since its content rarely + /// changes, the root payload is cached for a full day so app-resume and + /// connectivity-change auto-refetch triggers do not re-hit the slow root + /// endpoint within the same day. To avoid a long wait on the very first + /// open of the Files page, `prefetchRootListing` (called from `main`) + /// kicks off an async warm-up fetch in the background while the user is + /// still on the launch screen / other modules. Subfolders keep the + /// previous "always refetch on visit" TTL because their content changes + /// more often. Explicit user refreshes (rename, delete, copy/move, + /// upload) bypass the TTL via the inherited [renew] flag or via + /// [invalidate]. + static int _cacheTimeFor(String path) { + final stripped = path.replaceAll('/', '').trim(); + return stripped.isEmpty + ? RequestCache.cacheDay + : RequestCache.cacheNothing; + } + + /// Triggers a root-listing fetch in the background if no cached payload + /// exists yet. Intended to be called once after login from `main` so the + /// (slow) root listing is already populated by the time the user + /// navigates to the Files module. + /// + /// No-ops when a cached root payload is already present in localstore — + /// the regular TTL handling in [RequestCache] takes over from there. + static Future prefetchRootListing() async { + const rootPath = ''; + final cached = await Localstore.instance + .collection(RequestCache.collection) + .doc(_documentId(rootPath)) + .get(); + if (cached != null) return; + // Drive the same code path as a regular fetch so the result lands in + // the cache; we don't care about the in-memory callback here. + ListFilesCache(path: rootPath, onUpdate: (_) {}); + } + static String _documentId(String path) { final cacheName = md5 .convert(utf8.encode('MarianumMobile-$path')) diff --git a/lib/main.dart b/lib/main.dart index ad7199e..f003df9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,6 +17,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'api/marianumcloud/webdav/queries/list_files/list_files_cache.dart'; import 'api/mhsl/breaker/get_breakers/get_breakers_response.dart'; import 'app.dart'; import 'background/widget_background_task.dart'; @@ -91,6 +92,20 @@ Future main() async { ), ); + // Warm up the Nextcloud root listing in the background while the user is + // still on the launch screen / other modules — the root endpoint is slow + // on our instance, so kicking it off early means the Files page already + // has data ready by the time the user navigates to it. No-op when a + // cached payload is already present, so this does not undo the day-long + // root cache TTL. + if (AccountData().isPopulated()) { + unawaited( + ListFilesCache.prefetchRootListing().onError( + (e, _) => log('Files root prefetch failed: $e'), + ), + ); + } + if (kReleaseMode) { ErrorWidget.builder = (error) => Material( color: Colors.white, diff --git a/lib/state/app/modules/files/bloc/files_bloc.dart b/lib/state/app/modules/files/bloc/files_bloc.dart index e8753c9..c7e41fd 100644 --- a/lib/state/app/modules/files/bloc/files_bloc.dart +++ b/lib/state/app/modules/files/bloc/files_bloc.dart @@ -37,7 +37,9 @@ class FilesBloc Future refresh() async { add(RefetchStarted()); final path = innerState?.currentPath ?? initialPath; - await _query(path); + // Explicit user action — bypass the cache TTL so the root listing also + // refetches even though it is otherwise cached for a day. + await _query(path, renew: true); } Future setPath(List path) async { @@ -52,7 +54,7 @@ class FilesBloc await refresh(); } - Future _query(List path) async { + Future _query(List path, {bool renew = false}) async { final pathString = path.isEmpty ? '/' : path.join('/'); // Drop late results when [setPath] has navigated elsewhere or when the @@ -71,6 +73,7 @@ class FilesBloc try { listing = await repo.data.listFiles( pathString, + renew: renew, onCacheData: (cached) { if (isStale()) return; // Cached payload arrives before the network call settles. Surface it diff --git a/lib/state/app/modules/files/data_provider/files_data_provider.dart b/lib/state/app/modules/files/data_provider/files_data_provider.dart index e721fbb..3d76400 100644 --- a/lib/state/app/modules/files/data_provider/files_data_provider.dart +++ b/lib/state/app/modules/files/data_provider/files_data_provider.dart @@ -11,16 +11,23 @@ class FilesDataProvider { /// network call is still pending. The Future itself resolves once both the /// cache lookup and the network attempt have settled, throwing if no payload /// could be obtained at all. + /// + /// Pass [renew] for explicit user-triggered reloads (pull-to-refresh, after + /// a rename / delete / move / upload). It bypasses the per-path TTL in + /// [ListFilesCache] so the root listing — which is otherwise cached for a + /// full day — still refetches when the user actively asks for it. Future listFiles( String path, { void Function(ListFilesResponse)? onCacheData, void Function(Object)? onError, + bool renew = false, }) => resolveFromCache( (onUpdate, onError) => ListFilesCache( path: path, onUpdate: onUpdate, onCacheData: onCacheData, onError: onError, + renew: renew, ), onError: onError, operationName: 'listFiles', From 15833f3685f5fe977bc50ce41fe04a57f6532b86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sat, 9 May 2026 23:40:04 +0200 Subject: [PATCH 08/11] implemented disposal guard in files search controller to safely handle async listener notifications --- .../files/search/files_search_controller.dart | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/lib/view/pages/files/search/files_search_controller.dart b/lib/view/pages/files/search/files_search_controller.dart index ed2fee3..5a75249 100644 --- a/lib/view/pages/files/search/files_search_controller.dart +++ b/lib/view/pages/files/search/files_search_controller.dart @@ -25,6 +25,16 @@ class FilesSearchController extends ChangeNotifier { bool _serverLoading = false; Object? _serverError; int _serverEpoch = 0; + bool _disposed = false; + + /// Guards against the race where the search delegate is closed (and the + /// controller disposed) while a debounced cache scan or server call is + /// still in flight: their late `notifyListeners()` would otherwise throw + /// on a disposed `ChangeNotifier`. + void _safeNotify() { + if (_disposed) return; + _safeNotify(); + } String get query => _query; List get pathScope => List.unmodifiable(_pathScope); @@ -60,7 +70,7 @@ class FilesSearchController extends ChangeNotifier { _serverResults = const []; _serverLoading = false; _serverError = null; - notifyListeners(); + _safeNotify(); return; } // Show loading immediately — even before the (typically fast) cache @@ -68,12 +78,12 @@ class FilesSearchController extends ChangeNotifier { // starts typing rather than after the first await hop. _serverLoading = true; _serverError = null; - notifyListeners(); + _safeNotify(); final cacheHits = await searchLocalCaches(_query, pathScope: _pathScope); if (epoch != _serverEpoch) return; _cacheResults = cacheHits; - notifyListeners(); + _safeNotify(); _scheduleServerCall(); } @@ -84,17 +94,17 @@ class FilesSearchController extends ChangeNotifier { _pathScope = const []; final epoch = ++_serverEpoch; if (_query.trim().isEmpty) { - notifyListeners(); + _safeNotify(); return; } _serverLoading = true; _serverError = null; - notifyListeners(); + _safeNotify(); final cacheHits = await searchLocalCaches(_query); if (epoch != _serverEpoch) return; _cacheResults = cacheHits; - notifyListeners(); + _safeNotify(); _scheduleServerCall(); } @@ -106,7 +116,7 @@ class FilesSearchController extends ChangeNotifier { Debouncer.cancel(_debounceTag); _serverLoading = true; _serverError = null; - notifyListeners(); + _safeNotify(); _runServerCall(); } @@ -128,18 +138,19 @@ class FilesSearchController extends ChangeNotifier { .toList(); _serverLoading = false; _serverError = null; - notifyListeners(); + _safeNotify(); } on Object catch (e) { if (epoch != _serverEpoch) return; _serverResults = const []; _serverLoading = false; _serverError = e; - notifyListeners(); + _safeNotify(); } } @override void dispose() { + _disposed = true; Debouncer.cancel(_debounceTag); super.dispose(); } From c50a850ac986000e8b6d69c71d1df38e08fbb4ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sat, 9 May 2026 23:43:29 +0200 Subject: [PATCH 09/11] reordered files app bar actions by moving search icon --- lib/view/pages/files/files.dart | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/view/pages/files/files.dart b/lib/view/pages/files/files.dart index 54943d2..649427b 100644 --- a/lib/view/pages/files/files.dart +++ b/lib/view/pages/files/files.dart @@ -102,15 +102,6 @@ class _FilesViewState extends State<_FilesView> { appBar: AppBar( title: Text(widget.path.isNotEmpty ? widget.path.last : 'Dateien'), actions: [ - IconButton( - tooltip: 'Suchen', - icon: const Icon(Icons.search), - onPressed: () async { - final delegate = FilesSearchDelegate(pathScope: widget.path); - await showSearch(context: context, delegate: delegate); - delegate.disposeController(); - }, - ), FilesSortActions( currentSort: currentSort, ascending: currentSortDirection, @@ -127,6 +118,15 @@ class _FilesViewState extends State<_FilesView> { }); }, ), + IconButton( + tooltip: 'Suchen', + icon: const Icon(Icons.search), + onPressed: () async { + final delegate = FilesSearchDelegate(pathScope: widget.path); + await showSearch(context: context, delegate: delegate); + delegate.disposeController(); + }, + ), ], ), floatingActionButton: FloatingActionButton( From 1ff57b29f9717ad1a72b4426f24c2fe95887275f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 10 May 2026 00:33:09 +0200 Subject: [PATCH 10/11] overhauled file viewer with video, audio, text, and SVG support, added media player and line-numbered text views, and fixed search controller recursion --- .../files/search/files_search_controller.dart | 2 +- lib/widget/file_viewer.dart | 859 ++++++++++++++---- pubspec.yaml | 2 + 3 files changed, 710 insertions(+), 153 deletions(-) diff --git a/lib/view/pages/files/search/files_search_controller.dart b/lib/view/pages/files/search/files_search_controller.dart index 5a75249..21e0399 100644 --- a/lib/view/pages/files/search/files_search_controller.dart +++ b/lib/view/pages/files/search/files_search_controller.dart @@ -33,7 +33,7 @@ class FilesSearchController extends ChangeNotifier { /// on a disposed `ChangeNotifier`. void _safeNotify() { if (_disposed) return; - _safeNotify(); + notifyListeners(); } String get query => _query; diff --git a/lib/widget/file_viewer.dart b/lib/widget/file_viewer.dart index 9d3ab72..55b09ef 100644 --- a/lib/widget/file_viewer.dart +++ b/lib/widget/file_viewer.dart @@ -1,20 +1,25 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'dart:math'; +import 'package:chewie/chewie.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:open_filex/open_filex.dart'; import 'package:photo_view/photo_view.dart'; import 'package:share_plus/share_plus.dart'; import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; +import 'package:video_player/video_player.dart'; import '../routing/app_routes.dart'; import '../share_intent/remote_file_ref.dart'; import '../state/app/modules/settings/bloc/settings_cubit.dart'; +import 'app_progress_indicator.dart'; +import 'centered_leading.dart'; import 'info_dialog.dart'; -import 'placeholder_view.dart'; import 'share_position_origin.dart'; class FileViewer extends StatefulWidget { @@ -39,6 +44,88 @@ class FileViewer extends StatefulWidget { enum FileViewingActions { openExternal, share, save, sendToChat, saveToCloud } +enum _FileKind { image, svg, pdf, text, video, audio, unknown } + +const Set _imageExtensions = { + 'png', + 'jpg', + 'jpeg', + 'webp', + 'gif', + 'bmp', + 'wbmp', +}; + +/// Video container formats whose playback the platform decoders (ExoPlayer +/// on Android, AVPlayer on iOS) handle out of the box. +const Set _videoExtensions = { + 'mp4', + 'm4v', + 'mov', + 'webm', + 'mkv', + '3gp', +}; + +/// Audio formats playable through the same `video_player` pipeline. Some +/// (ogg/opus/flac) work on Android only — iOS will surface an init error +/// which we catch and surface as a friendly fallback. +const Set _audioExtensions = { + 'mp3', + 'm4a', + 'aac', + 'wav', + 'flac', + 'ogg', + 'oga', + 'opus', +}; + +/// Extensions whose contents we render directly as plain text. Anything +/// outside this list still gets a content-based fallback check (see +/// [_looksLikeText]) so generic "what is this file" cases work too. +const Set _textExtensions = { + 'txt', 'md', 'markdown', 'rst', 'log', + 'json', 'json5', 'xml', 'yaml', 'yml', 'toml', + 'csv', 'tsv', 'tab', + 'ini', 'conf', 'cfg', 'env', 'properties', + 'html', 'htm', 'xhtml', + 'css', 'scss', 'sass', 'less', + 'js', 'mjs', 'cjs', 'ts', 'jsx', 'tsx', + 'dart', 'java', 'kt', 'kts', 'groovy', 'scala', 'swift', + 'py', 'rb', 'pl', 'lua', 'r', + 'go', 'rs', 'zig', + 'c', 'cpp', 'cc', 'cxx', 'h', 'hpp', 'cs', 'm', 'mm', + 'php', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd', + 'sql', 'graphql', 'gql', + 'gitignore', 'gitattributes', 'editorconfig', 'dockerignore', + 'dockerfile', 'makefile', 'cmake', + 'tex', 'bib', + 'srt', 'vtt', +}; + +/// Reads up to 8 KB and decides whether the bytes look like UTF-8 text. +/// NUL bytes and non-decodable sequences disqualify the file. Used as a +/// fallback for unknown extensions so plain text files without a familiar +/// suffix still open in the in-app viewer. +Future _looksLikeText(String path) async { + final file = File(path); + RandomAccessFile? raf; + try { + final length = await file.length(); + if (length == 0) return true; + raf = await file.open(); + final sample = await raf.read(min(length, 8192)); + if (sample.contains(0)) return false; + utf8.decode(sample); + return true; + } on Object { + return false; + } finally { + await raf?.close(); + } +} + /// Workaround for a Syncfusion PDF viewer race: SfPdfViewer's internal /// LayoutBuilder calls `localToGlobal` during build, which asserts when an /// ancestor RenderTransform (from the page-push animation) is still mid-layout. @@ -82,7 +169,7 @@ class _DeferredPdfViewerState extends State<_DeferredPdfViewer> { @override Widget build(BuildContext context) { if (!_ready) { - return const Center(child: CircularProgressIndicator()); + return const Center(child: AppProgressIndicator.large()); } return SfPdfViewer.file(File(widget.path)); } @@ -93,13 +180,32 @@ class _FileViewerState extends State { late SettingsCubit settings = context.read(); late bool openExternal; + Future<_FileKind>? _fileKind; @override void initState() { + super.initState(); openExternal = settings.val().fileViewSettings.alwaysOpenExternally || widget.openExternal; - super.initState(); + if (openExternal) { + // Settings or popup explicitly chose "open externally" — fire and + // forget, then pop back. Same one-shot behaviour as the old viewer. + WidgetsBinding.instance.addPostFrameCallback( + (_) => _openExternallyAndPop(), + ); + } else { + _fileKind = _detectKind(); + } + } + + Future _openExternallyAndPop() async { + final result = await OpenFilex.open(widget.path); + if (!mounted) return; + Navigator.of(context).pop(); + if (result.type != ResultType.done) { + InfoDialog.show(context, result.message); + } } @override @@ -108,167 +214,616 @@ class _FileViewerState extends State { super.dispose(); } + Future<_FileKind> _detectKind() async { + final ext = widget.path.split('.').last.toLowerCase(); + if (_imageExtensions.contains(ext)) return _FileKind.image; + if (ext == 'svg') return _FileKind.svg; + if (ext == 'pdf') return _FileKind.pdf; + if (_videoExtensions.contains(ext)) return _FileKind.video; + if (_audioExtensions.contains(ext)) return _FileKind.audio; + if (_textExtensions.contains(ext)) return _FileKind.text; + if (await _looksLikeText(widget.path)) return _FileKind.text; + return _FileKind.unknown; + } + + Future _handleAction(FileViewingActions value) async { + switch (value) { + case FileViewingActions.openExternal: + AppRoutes.openFileViewer( + context, + widget.path, + openExternal: true, + remoteFile: widget.remoteFile, + ); + break; + case FileViewingActions.sendToChat: + AppRoutes.openInternalShareToChat(context, widget.remoteFile!); + break; + case FileViewingActions.saveToCloud: + AppRoutes.openInternalSaveToFolder(context, widget.remoteFile!); + break; + case FileViewingActions.share: + unawaited( + SharePlus.instance.share( + ShareParams( + files: [XFile(widget.path)], + sharePositionOrigin: SharePositionOrigin.get(context), + ), + ), + ); + break; + case FileViewingActions.save: + try { + final bytes = await File(widget.path).readAsBytes(); + final saved = await FilePicker.saveFile( + fileName: widget.path.split('/').last, + bytes: bytes, + ); + if (!mounted) return; + if (saved != null) { + InfoDialog.show(context, 'Datei gespeichert.'); + } + } on Object catch (e) { + if (!mounted) return; + InfoDialog.show( + context, + 'Speichern fehlgeschlagen: $e', + copyable: true, + title: 'Fehler', + ); + } + break; + } + } + + List<_ActionDescriptor> _availableActions() => [ + _ActionDescriptor( + action: FileViewingActions.openExternal, + // iOS opens the system share sheet (square-with-arrow icon), Android + // the standard app picker; mirror that visually and verbally. + icon: Platform.isIOS ? Icons.ios_share : Icons.open_in_new, + label: Platform.isIOS ? 'Extern öffnen' : 'Öffnen mit', + ), + if (widget.remoteFile != null) ...[ + const _ActionDescriptor( + action: FileViewingActions.sendToChat, + icon: Icons.chat_bubble_outline, + label: 'An Talk-Chat senden', + ), + const _ActionDescriptor( + action: FileViewingActions.saveToCloud, + icon: Icons.cloud_outlined, + label: 'In Cloud speichern', + ), + ], + const _ActionDescriptor( + action: FileViewingActions.share, + icon: Icons.share_outlined, + label: 'Teilen', + ), + const _ActionDescriptor( + action: FileViewingActions.save, + icon: Icons.save_alt_outlined, + label: 'Speichern', + ), + ]; + + AppBar _appbar({ + List actions = const [], + bool showActionsMenu = true, + }) => AppBar( + title: Text(widget.path.split('/').last), + actions: [ + ...actions, + if (showActionsMenu) + PopupMenuButton( + onSelected: _handleAction, + itemBuilder: (context) => _availableActions() + .map( + (a) => PopupMenuItem( + value: a.action, + child: ListTile( + leading: Icon(a.icon), + title: Text(a.label), + dense: true, + ), + ), + ) + .toList(), + ), + ], + ); + @override Widget build(BuildContext context) { - AppBar appbar({List actions = const []}) => AppBar( - title: Text(widget.path.split('/').last), + if (openExternal) { + return Scaffold( + appBar: AppBar(title: Text(widget.path.split('/').last)), + body: const Center(child: AppProgressIndicator.large()), + ); + } + return FutureBuilder<_FileKind>( + future: _fileKind, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Scaffold( + appBar: _appbar(), + body: const Center(child: AppProgressIndicator.large()), + ); + } + switch (snapshot.data!) { + case _FileKind.image: + return _buildImageView(); + case _FileKind.svg: + return _buildSvgView(); + case _FileKind.pdf: + return _buildPdfView(); + case _FileKind.video: + return _buildVideoView(); + case _FileKind.audio: + return _buildAudioView(); + case _FileKind.text: + return _buildTextView(); + case _FileKind.unknown: + return _buildUnknownView(); + } + }, + ); + } + + Widget _buildImageView() => Scaffold( + appBar: _appbar( actions: [ - ...actions, - PopupMenuButton( - onSelected: (value) async { - switch (value) { - case FileViewingActions.openExternal: - AppRoutes.openFileViewer( - context, - widget.path, - openExternal: true, - remoteFile: widget.remoteFile, - ); - break; - case FileViewingActions.sendToChat: - AppRoutes.openInternalShareToChat(context, widget.remoteFile!); - break; - case FileViewingActions.saveToCloud: - AppRoutes.openInternalSaveToFolder( - context, - widget.remoteFile!, - ); - break; - case FileViewingActions.share: - unawaited( - SharePlus.instance.share( - ShareParams( - files: [XFile(widget.path)], - sharePositionOrigin: SharePositionOrigin.get(context), - ), - ), - ); - break; - case FileViewingActions.save: - try { - final bytes = await File(widget.path).readAsBytes(); - final saved = await FilePicker.saveFile( - fileName: widget.path.split('/').last, - bytes: bytes, - ); - if (!context.mounted) return; - if (saved != null) { - InfoDialog.show(context, 'Datei gespeichert.'); - } - } on Object catch (e) { - if (!context.mounted) return; - InfoDialog.show( - context, - 'Speichern fehlgeschlagen: $e', - copyable: true, - title: 'Fehler', - ); - } - break; - } + IconButton( + onPressed: () { + setState(() { + photoViewController.rotation += pi / 2; + }); }, - itemBuilder: (context) => >[ - const PopupMenuItem( - value: FileViewingActions.openExternal, - child: ListTile( - leading: Icon(Icons.open_in_new), - title: Text('Extern öffnen'), - dense: true, - ), - ), - if (widget.remoteFile != null) ...[ - const PopupMenuItem( - value: FileViewingActions.sendToChat, - child: ListTile( - leading: Icon(Icons.chat_bubble_outline), - title: Text('An Talk-Chat senden'), - dense: true, - ), - ), - const PopupMenuItem( - value: FileViewingActions.saveToCloud, - child: ListTile( - leading: Icon(Icons.cloud_outlined), - title: Text('In Cloud speichern'), - dense: true, - ), - ), - ], - const PopupMenuItem( - value: FileViewingActions.share, - child: ListTile( - leading: Icon(Icons.share_outlined), - title: Text('Teilen'), - dense: true, - ), - ), - const PopupMenuItem( - value: FileViewingActions.save, - child: ListTile( - leading: Icon(Icons.save_alt_outlined), - title: Text('Speichern'), - dense: true, - ), - ), - ], + icon: const Icon(Icons.rotate_right), ), ], - ); + ), + backgroundColor: Colors.white, + body: PhotoView( + controller: photoViewController, + maxScale: 3.0, + minScale: 0.1, + imageProvider: Image.file(File(widget.path)).image, + backgroundDecoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + ), + ), + ); - switch (openExternal ? '' : widget.path.split('.').last.toLowerCase()) { - case 'png': - case 'jpg': - case 'jpeg': - case 'webp': - case 'gif': - return Scaffold( - appBar: appbar( - actions: [ - IconButton( - onPressed: () { - setState(() { - photoViewController.rotation += pi / 2; - }); - }, - icon: const Icon(Icons.rotate_right), - ), - ], - ), - backgroundColor: Colors.white, - body: PhotoView( - controller: photoViewController, - maxScale: 3.0, - minScale: 0.1, - imageProvider: Image.file(File(widget.path)).image, - backgroundDecoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, + Widget _buildSvgView() => Scaffold( + appBar: _appbar(), + backgroundColor: Colors.white, + body: InteractiveViewer( + minScale: 0.5, + maxScale: 8, + child: Center( + child: SvgPicture.file( + File(widget.path), + placeholderBuilder: (_) => + const Center(child: AppProgressIndicator.large()), + ), + ), + ), + ); + + Widget _buildPdfView() => + Scaffold(appBar: _appbar(), body: _DeferredPdfViewer(path: widget.path)); + + Widget _buildVideoView() => Scaffold( + appBar: _appbar(), + backgroundColor: Colors.black, + body: _MediaPlayer(path: widget.path, isAudio: false), + ); + + Widget _buildAudioView() => Scaffold( + appBar: _appbar(), + body: _MediaPlayer( + path: widget.path, + isAudio: true, + filename: widget.path.split('/').last, + ), + ); + + Widget _buildTextView() => Scaffold( + appBar: _appbar(), + body: FutureBuilder<_TextPayload>( + future: _readTextPayload(), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center(child: AppProgressIndicator.large()); + } + final payload = snapshot.data!; + final lines = const LineSplitter().convert(payload.content); + // Reserve gutter width by the digit count of the highest line number, + // so the gutter stays stable as the user scrolls down. + final gutterWidth = (lines.length.toString().length * 9.0) + 16; + return SelectionArea( + child: Scrollbar( + child: CustomScrollView( + slivers: [ + if (payload.truncated) + SliverToBoxAdapter( + child: SelectionContainer.disabled( + child: Container( + width: double.infinity, + color: Theme.of( + context, + ).colorScheme.surfaceContainerHigh, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Text( + 'Datei ist groß — Anzeige auf die ersten ${(_textViewMaxBytes / 1024).round()} KB begrenzt.', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ), + ), + SliverList.builder( + itemCount: lines.length, + itemBuilder: (context, i) => _CodeLine( + number: i + 1, + text: lines[i], + gutterWidth: gutterWidth, + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 24)), + ], ), ), ); + }, + ), + ); - case 'pdf': - return Scaffold( - appBar: appbar(), - body: _DeferredPdfViewer(path: widget.path), - ); - - default: - OpenFilex.open(widget.path).then((result) { - if (!context.mounted) return; - Navigator.of(context).pop(); - if (result.type != ResultType.done) { - InfoDialog.show(context, result.message); - } - }); - - return PlaceholderView( - text: 'Datei extern geöffnet', - icon: Icons.open_in_new, - button: TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Zurück'), + Widget _buildUnknownView() { + final theme = Theme.of(context); + final descriptors = _availableActions(); + return Scaffold( + appBar: _appbar(showActionsMenu: false), + body: ListView( + padding: const EdgeInsets.symmetric(vertical: 24), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + const Icon(Icons.insert_drive_file_outlined, size: 60), + const SizedBox(height: 16), + Text( + 'Vorschau nicht verfügbar', + style: theme.textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + Text( + widget.path.split('/').last, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Wähle eine Aktion, um mit der Datei weiterzuarbeiten.', + style: theme.textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ], + ), ), - ); + const SizedBox(height: 24), + ...descriptors.map( + (d) => ListTile( + leading: CenteredLeading(Icon(d.icon)), + title: Text(d.label), + onTap: () => _handleAction(d.action), + ), + ), + ], + ), + ); + } + + static const int _textViewMaxBytes = 5 * 1024 * 1024; + + Future<_TextPayload> _readTextPayload() async { + final file = File(widget.path); + final size = await file.length(); + final ext = widget.path.split('.').last.toLowerCase(); + if (size <= _textViewMaxBytes) { + final raw = await file.readAsString(); + return _TextPayload(content: _maybePrettify(raw, ext), truncated: false); + } + final raf = await file.open(); + try { + final bytes = await raf.read(_textViewMaxBytes); + // Truncated payloads cannot be reliably re-formatted (parser will + // choke on the dangling tail), so they stay raw. + return _TextPayload( + content: utf8.decode(bytes, allowMalformed: true), + truncated: true, + ); + } finally { + await raf.close(); + } + } + + /// Re-indents JSON so dumped/minified payloads from the server are easier + /// to read. Falls through to the original text on parse errors so we + /// never destroy the user's content. + String _maybePrettify(String content, String ext) { + if (ext != 'json') return content; + try { + final parsed = jsonDecode(content); + return const JsonEncoder.withIndent(' ').convert(parsed); + } on Object { + return content; } } } + +class _ActionDescriptor { + final FileViewingActions action; + final IconData icon; + final String label; + const _ActionDescriptor({ + required this.action, + required this.icon, + required this.label, + }); +} + +class _TextPayload { + final String content; + final bool truncated; + const _TextPayload({required this.content, required this.truncated}); +} + +/// Plays back a local file via `video_player`. Renders the standard Chewie +/// controls for video files; audio files get a centered icon plus a custom +/// transport row (slider, time, play/pause), since Chewie's chrome is +/// designed around a video frame. +class _MediaPlayer extends StatefulWidget { + final String path; + final bool isAudio; + final String? filename; + const _MediaPlayer({ + required this.path, + required this.isAudio, + this.filename, + }); + + @override + State<_MediaPlayer> createState() => _MediaPlayerState(); +} + +class _MediaPlayerState extends State<_MediaPlayer> { + VideoPlayerController? _video; + ChewieController? _chewie; + Object? _initError; + + @override + void initState() { + super.initState(); + _initialize(); + } + + Future _initialize() async { + final controller = VideoPlayerController.file(File(widget.path)); + try { + await controller.initialize(); + } on Object catch (e) { + await controller.dispose(); + if (!mounted) return; + setState(() => _initError = e); + return; + } + if (!mounted) { + await controller.dispose(); + return; + } + if (widget.isAudio) { + controller.addListener(_onAudioTick); + setState(() => _video = controller); + } else { + setState(() { + _video = controller; + _chewie = ChewieController( + videoPlayerController: controller, + autoPlay: false, + looping: false, + allowFullScreen: true, + allowPlaybackSpeedChanging: true, + ); + }); + } + } + + void _onAudioTick() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _video?.removeListener(_onAudioTick); + _chewie?.dispose(); + _video?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_initError != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48), + const SizedBox(height: 12), + Text( + widget.isAudio + ? 'Audio kann nicht abgespielt werden' + : 'Video kann nicht abgespielt werden', + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Format wird auf diesem Gerät nicht unterstützt. Über das Menü kannst du die Datei in einer anderen App öffnen.', + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + if (_video == null) { + return const Center(child: AppProgressIndicator.large()); + } + if (widget.isAudio) { + return _AudioControls( + controller: _video!, + filename: widget.filename ?? '', + ); + } + return Chewie(controller: _chewie!); + } +} + +class _AudioControls extends StatelessWidget { + final VideoPlayerController controller; + final String filename; + const _AudioControls({required this.controller, required this.filename}); + + String _format(Duration d) { + final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); + final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + if (d.inHours > 0) return '${d.inHours}:$m:$s'; + return '$m:$s'; + } + + @override + Widget build(BuildContext context) { + final value = controller.value; + final duration = value.duration; + final position = value.position; + final maxMs = duration.inMilliseconds == 0 ? 1 : duration.inMilliseconds; + final posMs = position.inMilliseconds.clamp(0, maxMs).toDouble(); + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.audiotrack, + size: 96, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 24), + Text( + filename, + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + Slider( + min: 0, + max: maxMs.toDouble(), + value: posMs, + onChanged: (v) => + controller.seekTo(Duration(milliseconds: v.toInt())), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _format(position), + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + _format(duration), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + const SizedBox(height: 24), + FloatingActionButton( + heroTag: 'audioPlayPause', + onPressed: () { + if (value.isPlaying) { + controller.pause(); + } else { + controller.play(); + } + }, + child: Icon(value.isPlaying ? Icons.pause : Icons.play_arrow), + ), + ], + ), + ), + ); + } +} + +/// One row in the text viewer: line number on the left (not selectable so +/// it never ends up in copied selections), monospace content on the right. +/// Odd-numbered lines get a slightly tinted background so long files are +/// easier to scan. +class _CodeLine extends StatelessWidget { + final int number; + final String text; + final double gutterWidth; + const _CodeLine({ + required this.number, + required this.text, + required this.gutterWidth, + }); + + static const TextStyle _codeStyle = TextStyle( + fontFamily: 'monospace', + fontSize: 13, + height: 1.4, + ); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isEven = number.isEven; + return Container( + color: isEven ? theme.colorScheme.surfaceContainerLow : null, + padding: const EdgeInsets.only(left: 4, right: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectionContainer.disabled( + child: SizedBox( + width: gutterWidth, + child: Text( + '$number', + textAlign: TextAlign.right, + style: _codeStyle.copyWith(color: theme.hintColor), + ), + ), + ), + const SizedBox(width: 8), + Expanded(child: Text(text.isEmpty ? ' ' : text, style: _codeStyle)), + ], + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 34428e3..660c69b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -74,6 +74,8 @@ dependencies: url_launcher: ^6.3.1 enough_icalendar: ^0.17.0 receive_sharing_intent: ^1.8.1 + video_player: ^2.9.0 + chewie: ^1.8.5 dev_dependencies: flutter_test: From ed2badfd3525eabb975dd001a766eec0b7d276ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 10 May 2026 00:54:13 +0200 Subject: [PATCH 11/11] fixed chat bubble link styling and gesture handling, and added android package visibility for common schemes --- android/app/src/main/AndroidManifest.xml | 28 +++++++++++++++---- lib/view/pages/talk/widgets/chat_bubble.dart | 14 +++++++++- .../talk/widgets/highlighted_linkify.dart | 18 ++++++++++-- 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3b12681..3bfcf55 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -73,16 +73,34 @@ android:resource="@xml/timetable_week_widget_info" /> - + + + + + + + + + + + + + + + + +