Merge pull request 'general search, talk enhancements, overhauled fileviewer' (#98) from develop-search into develop
Reviewed-on: #98
This commit was merged in pull request #98.
This commit is contained in:
@@ -73,16 +73,34 @@
|
|||||||
android:resource="@xml/timetable_week_widget_info" />
|
android:resource="@xml/timetable_week_widget_info" />
|
||||||
</receiver>
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required so url_launcher / can_launch can actually see browsers,
|
||||||
https://developer.android.com/training/package-visibility?hl=en and
|
mail clients and dialers under Android 11+ package-visibility rules
|
||||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
(otherwise UrlLauncher logs "component name for ... is null" and
|
||||||
|
link taps in Talk silently do nothing). The PROCESS_TEXT intent is
|
||||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
needed by io.flutter.plugin.text.ProcessTextPlugin (selection
|
||||||
|
menu).
|
||||||
|
See https://developer.android.com/training/package-visibility -->
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
<data android:mimeType="text/plain"/>
|
<data android:mimeType="text/plain"/>
|
||||||
</intent>
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<data android:scheme="https"/>
|
||||||
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<data android:scheme="http"/>
|
||||||
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<data android:scheme="mailto"/>
|
||||||
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<data android:scheme="tel"/>
|
||||||
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<!-- Workmanager periodic widget refresh needs to reschedule after device
|
<!-- Workmanager periodic widget refresh needs to reschedule after device
|
||||||
|
|||||||
@@ -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<SearchFilesResponse> 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<String, dynamic>;
|
||||||
|
final ocs = decoded['ocs'] as Map<String, dynamic>;
|
||||||
|
final data = ocs['data'] as Map<String, dynamic>;
|
||||||
|
return SearchFilesResponse.fromJson(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SearchFilesEntry> entries;
|
||||||
|
|
||||||
|
SearchFilesResponse({
|
||||||
|
required this.name,
|
||||||
|
required this.isPaginated,
|
||||||
|
required this.cursor,
|
||||||
|
required this.entries,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory SearchFilesResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$SearchFilesResponseFromJson(json);
|
||||||
|
Map<String, dynamic> toJson() => _$SearchFilesResponseToJson(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonSerializable()
|
||||||
|
class SearchFilesEntry {
|
||||||
|
final String title;
|
||||||
|
final String? subline;
|
||||||
|
final String? icon;
|
||||||
|
final String? resourceUrl;
|
||||||
|
final Map<String, dynamic>? attributes;
|
||||||
|
|
||||||
|
SearchFilesEntry({
|
||||||
|
required this.title,
|
||||||
|
this.subline,
|
||||||
|
this.icon,
|
||||||
|
this.resourceUrl,
|
||||||
|
this.attributes,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory SearchFilesEntry.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$SearchFilesEntryFromJson(json);
|
||||||
|
Map<String, dynamic> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'search_files_response.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
SearchFilesResponse _$SearchFilesResponseFromJson(Map<String, dynamic> json) =>
|
||||||
|
SearchFilesResponse(
|
||||||
|
name: json['name'] as String,
|
||||||
|
isPaginated: json['isPaginated'] as bool,
|
||||||
|
cursor: (json['cursor'] as num?)?.toInt(),
|
||||||
|
entries: (json['entries'] as List<dynamic>)
|
||||||
|
.map((e) => SearchFilesEntry.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$SearchFilesResponseToJson(
|
||||||
|
SearchFilesResponse instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'name': instance.name,
|
||||||
|
'isPaginated': instance.isPaginated,
|
||||||
|
'cursor': instance.cursor,
|
||||||
|
'entries': instance.entries.map((e) => e.toJson()).toList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
SearchFilesEntry _$SearchFilesEntryFromJson(Map<String, dynamic> 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<String, dynamic>?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$SearchFilesEntryToJson(SearchFilesEntry instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'title': instance.title,
|
||||||
|
'subline': instance.subline,
|
||||||
|
'icon': instance.icon,
|
||||||
|
'resourceUrl': instance.resourceUrl,
|
||||||
|
'attributes': instance.attributes,
|
||||||
|
};
|
||||||
@@ -66,7 +66,7 @@ class GetChatResponseObject {
|
|||||||
|
|
||||||
static GetChatResponseObject getDateDummy(int timestamp) {
|
static GetChatResponseObject getDateDummy(int timestamp) {
|
||||||
var elementDate = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
|
var elementDate = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
|
||||||
return getTextDummy(elementDate.formatDate());
|
return getTextDummy(elementDate.formatDateRelativeShort());
|
||||||
}
|
}
|
||||||
|
|
||||||
static GetChatResponseObject getTextDummy(String text) =>
|
static GetChatResponseObject getTextDummy(String text) =>
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
|
|||||||
super.onNetworkData,
|
super.onNetworkData,
|
||||||
super.onError,
|
super.onError,
|
||||||
required String path,
|
required String path,
|
||||||
|
super.renew = false,
|
||||||
}) : super(
|
}) : super(
|
||||||
cacheTime: RequestCache.cacheNothing,
|
cacheTime: _cacheTimeFor(path),
|
||||||
loader: () => ListFiles(ListFilesParams(path)).run(),
|
loader: () => ListFiles(ListFilesParams(path)).run(),
|
||||||
fromJson: ListFilesResponse.fromJson,
|
fromJson: ListFilesResponse.fromJson,
|
||||||
onUpdate: onUpdate,
|
onUpdate: onUpdate,
|
||||||
@@ -25,6 +26,44 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
|
|||||||
start(_documentId(path));
|
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<void> 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) {
|
static String _documentId(String path) {
|
||||||
final cacheName = md5
|
final cacheName = md5
|
||||||
.convert(utf8.encode('MarianumMobile-$path'))
|
.convert(utf8.encode('MarianumMobile-$path'))
|
||||||
|
|||||||
@@ -46,4 +46,18 @@ extension DateTimeFormatting on DateTime {
|
|||||||
String formatRelative() => Jiffy.parseFromDateTime(this).fromNow();
|
String formatRelative() => Jiffy.parseFromDateTime(this).fromNow();
|
||||||
|
|
||||||
String timeRangeTo(DateTime end) => '${formatHm()} - ${end.formatHm()}';
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.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 'api/mhsl/breaker/get_breakers/get_breakers_response.dart';
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
import 'background/widget_background_task.dart';
|
import 'background/widget_background_task.dart';
|
||||||
@@ -91,6 +92,20 @@ Future<void> 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) {
|
if (kReleaseMode) {
|
||||||
ErrorWidget.builder = (error) => Material(
|
ErrorWidget.builder = (error) => Material(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ class FilesBloc
|
|||||||
Future<void> refresh() async {
|
Future<void> refresh() async {
|
||||||
add(RefetchStarted<FilesState>());
|
add(RefetchStarted<FilesState>());
|
||||||
final path = innerState?.currentPath ?? initialPath;
|
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<void> setPath(List<String> path) async {
|
Future<void> setPath(List<String> path) async {
|
||||||
@@ -52,7 +54,7 @@ class FilesBloc
|
|||||||
await refresh();
|
await refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _query(List<String> path) async {
|
Future<void> _query(List<String> path, {bool renew = false}) async {
|
||||||
final pathString = path.isEmpty ? '/' : path.join('/');
|
final pathString = path.isEmpty ? '/' : path.join('/');
|
||||||
|
|
||||||
// Drop late results when [setPath] has navigated elsewhere or when the
|
// Drop late results when [setPath] has navigated elsewhere or when the
|
||||||
@@ -71,6 +73,7 @@ class FilesBloc
|
|||||||
try {
|
try {
|
||||||
listing = await repo.data.listFiles(
|
listing = await repo.data.listFiles(
|
||||||
pathString,
|
pathString,
|
||||||
|
renew: renew,
|
||||||
onCacheData: (cached) {
|
onCacheData: (cached) {
|
||||||
if (isStale()) return;
|
if (isStale()) return;
|
||||||
// Cached payload arrives before the network call settles. Surface it
|
// Cached payload arrives before the network call settles. Surface it
|
||||||
|
|||||||
@@ -11,16 +11,23 @@ class FilesDataProvider {
|
|||||||
/// network call is still pending. The Future itself resolves once both the
|
/// network call is still pending. The Future itself resolves once both the
|
||||||
/// cache lookup and the network attempt have settled, throwing if no payload
|
/// cache lookup and the network attempt have settled, throwing if no payload
|
||||||
/// could be obtained at all.
|
/// 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<ListFilesResponse> listFiles(
|
Future<ListFilesResponse> listFiles(
|
||||||
String path, {
|
String path, {
|
||||||
void Function(ListFilesResponse)? onCacheData,
|
void Function(ListFilesResponse)? onCacheData,
|
||||||
void Function(Object)? onError,
|
void Function(Object)? onError,
|
||||||
|
bool renew = false,
|
||||||
}) => resolveFromCache<ListFilesResponse>(
|
}) => resolveFromCache<ListFilesResponse>(
|
||||||
(onUpdate, onError) => ListFilesCache(
|
(onUpdate, onError) => ListFilesCache(
|
||||||
path: path,
|
path: path,
|
||||||
onUpdate: onUpdate,
|
onUpdate: onUpdate,
|
||||||
onCacheData: onCacheData,
|
onCacheData: onCacheData,
|
||||||
onError: onError,
|
onError: onError,
|
||||||
|
renew: renew,
|
||||||
),
|
),
|
||||||
onError: onError,
|
onError: onError,
|
||||||
operationName: 'listFiles',
|
operationName: 'listFiles',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import '../../../utils/cache_invalidation_bus.dart';
|
|||||||
import '../../../widget/placeholder_view.dart';
|
import '../../../widget/placeholder_view.dart';
|
||||||
import 'data/sort_options.dart';
|
import 'data/sort_options.dart';
|
||||||
import 'files_upload_dialog.dart';
|
import 'files_upload_dialog.dart';
|
||||||
|
import 'search/files_search_delegate.dart';
|
||||||
import 'widgets/add_file_menu.dart';
|
import 'widgets/add_file_menu.dart';
|
||||||
import 'widgets/clipboard_banner.dart';
|
import 'widgets/clipboard_banner.dart';
|
||||||
import 'widgets/file_element.dart';
|
import 'widgets/file_element.dart';
|
||||||
@@ -117,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<void>(context: context, delegate: delegate);
|
||||||
|
delegate.disposeController();
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
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<String>? initialPathScope})
|
||||||
|
: _pathScope = List<String>.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<String> _pathScope;
|
||||||
|
List<CacheableFile> _cacheResults = const [];
|
||||||
|
List<CacheableFile> _serverResults = const [];
|
||||||
|
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;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
String get query => _query;
|
||||||
|
List<String> get pathScope => List.unmodifiable(_pathScope);
|
||||||
|
bool get isScoped => _pathScope.isNotEmpty;
|
||||||
|
List<CacheableFile> get cacheResults => _cacheResults;
|
||||||
|
List<CacheableFile> 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<CacheableFile> get combinedResults {
|
||||||
|
if (_cacheResults.isEmpty) return _serverResults;
|
||||||
|
if (_serverResults.isEmpty) return _cacheResults;
|
||||||
|
final seen = <String>{for (final f in _cacheResults) f.path};
|
||||||
|
return [
|
||||||
|
..._cacheResults,
|
||||||
|
..._serverResults.where((f) => seen.add(f.path)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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;
|
||||||
|
_safeNotify();
|
||||||
|
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;
|
||||||
|
_safeNotify();
|
||||||
|
|
||||||
|
final cacheHits = await searchLocalCaches(_query, pathScope: _pathScope);
|
||||||
|
if (epoch != _serverEpoch) return;
|
||||||
|
_cacheResults = cacheHits;
|
||||||
|
_safeNotify();
|
||||||
|
_scheduleServerCall();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops the path filter and re-runs the current search globally. Used by
|
||||||
|
/// the empty-state "Im Hauptverzeichnis suchen" button.
|
||||||
|
Future<void> searchEverywhere() async {
|
||||||
|
if (!isScoped) return;
|
||||||
|
_pathScope = const [];
|
||||||
|
final epoch = ++_serverEpoch;
|
||||||
|
if (_query.trim().isEmpty) {
|
||||||
|
_safeNotify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_serverLoading = true;
|
||||||
|
_serverError = null;
|
||||||
|
_safeNotify();
|
||||||
|
|
||||||
|
final cacheHits = await searchLocalCaches(_query);
|
||||||
|
if (epoch != _serverEpoch) return;
|
||||||
|
_cacheResults = cacheHits;
|
||||||
|
_safeNotify();
|
||||||
|
_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;
|
||||||
|
_safeNotify();
|
||||||
|
_runServerCall();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleServerCall() {
|
||||||
|
Debouncer.debounce(_debounceTag, _serverDebounce, _runServerCall);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<CacheableFile>()
|
||||||
|
.where((f) => scopePrefix.isEmpty || f.path.startsWith(scopePrefix))
|
||||||
|
.toList();
|
||||||
|
_serverLoading = false;
|
||||||
|
_serverError = null;
|
||||||
|
_safeNotify();
|
||||||
|
} on Object catch (e) {
|
||||||
|
if (epoch != _serverEpoch) return;
|
||||||
|
_serverResults = const [];
|
||||||
|
_serverLoading = false;
|
||||||
|
_serverError = e;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
Debouncer.cancel(_debounceTag);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
final FilesSearchController _controller;
|
||||||
|
|
||||||
|
FilesSearchDelegate({required List<String> 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<Widget>? 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LoadableStateBloc, LoadableStateState>(
|
||||||
|
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<CacheableFile> combined) {
|
||||||
|
final groups = _groupByParent(combined);
|
||||||
|
final orderedKeys = groups.keys.toList()..sort();
|
||||||
|
final items = <Widget>[];
|
||||||
|
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<String, List<CacheableFile>> _groupByParent(List<CacheableFile> files) {
|
||||||
|
final map = <String, List<CacheableFile>>{};
|
||||||
|
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<String> _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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<CacheableFile>> searchLocalCaches(
|
||||||
|
String query, {
|
||||||
|
List<String>? pathScope,
|
||||||
|
Map<String, dynamic>? 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 = <String, CacheableFile>{};
|
||||||
|
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<String, dynamic>,
|
||||||
|
);
|
||||||
|
} 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();
|
||||||
|
}
|
||||||
@@ -14,13 +14,25 @@ import '../../../../widget/centered_leading.dart';
|
|||||||
import '../../../../widget/confirm_dialog.dart';
|
import '../../../../widget/confirm_dialog.dart';
|
||||||
import '../../../../widget/details_bottom_sheet.dart';
|
import '../../../../widget/details_bottom_sheet.dart';
|
||||||
import '../../../../widget/info_dialog.dart';
|
import '../../../../widget/info_dialog.dart';
|
||||||
|
import '../../talk/widgets/highlighted_linkify.dart';
|
||||||
import 'file_details_sheet.dart';
|
import 'file_details_sheet.dart';
|
||||||
|
|
||||||
class FileElement extends StatefulWidget {
|
class FileElement extends StatefulWidget {
|
||||||
final CacheableFile file;
|
final CacheableFile file;
|
||||||
final List<String> path;
|
final List<String> path;
|
||||||
final void Function() refetch;
|
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
|
@override
|
||||||
State<FileElement> createState() => _FileElementState();
|
State<FileElement> createState() => _FileElementState();
|
||||||
@@ -118,7 +130,7 @@ class _FileElementState extends State<FileElement> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _subtitle() {
|
Widget? _subtitle() {
|
||||||
final status = _job?.status.value;
|
final status = _job?.status.value;
|
||||||
if (status is DownloadInProgress) {
|
if (status is DownloadInProgress) {
|
||||||
return Row(
|
return Row(
|
||||||
@@ -135,10 +147,16 @@ class _FileElementState extends State<FileElement> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final modified = widget.file.modifiedAt ?? DateTime.now();
|
final modified = widget.file.modifiedAt;
|
||||||
return widget.file.isDirectory
|
final size = widget.file.size;
|
||||||
? Text('geändert ${modified.formatRelative()}')
|
if (widget.file.isDirectory) {
|
||||||
: Text('${filesize(widget.file.size)}, ${modified.formatRelative()}');
|
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() {
|
void _onTap() {
|
||||||
@@ -328,12 +346,36 @@ class _FileElementState extends State<FileElement> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context) => ListTile(
|
Widget build(BuildContext context) => ListTile(
|
||||||
leading: CenteredLeading(
|
leading: CenteredLeading(
|
||||||
Icon(widget.file.isDirectory ? Icons.folder : Icons.description_outlined),
|
Icon(widget.file.isDirectory ? Icons.folder : Icons.description_outlined),
|
||||||
),
|
),
|
||||||
title: Text(widget.file.name, maxLines: 2, overflow: TextOverflow.ellipsis),
|
title: _title(context),
|
||||||
subtitle: _subtitle(),
|
subtitle: _subtitle(),
|
||||||
trailing: Icon(widget.file.isDirectory ? Icons.arrow_right : null),
|
trailing: Icon(widget.file.isDirectory ? Icons.arrow_right : null),
|
||||||
onTap: _onTap,
|
onTap: _onTap,
|
||||||
|
|||||||
@@ -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/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_bloc.dart';
|
||||||
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
|
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
|
||||||
|
import 'search_marianum_messages.dart';
|
||||||
|
|
||||||
class MarianumMessageListView extends StatelessWidget {
|
class MarianumMessageListView extends StatelessWidget {
|
||||||
const MarianumMessageListView({super.key});
|
const MarianumMessageListView({super.key});
|
||||||
@@ -16,7 +17,25 @@ class MarianumMessageListView extends StatelessWidget {
|
|||||||
) => BlocModule<MarianumMessageBloc, LoadableState<MarianumMessageState>>(
|
) => BlocModule<MarianumMessageBloc, LoadableState<MarianumMessageState>>(
|
||||||
create: (context) => MarianumMessageBloc(),
|
create: (context) => MarianumMessageBloc(),
|
||||||
child: (context, bloc, state) => Scaffold(
|
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<MarianumMessageBloc, MarianumMessageState>(
|
body: LoadableStateConsumer<MarianumMessageBloc, MarianumMessageState>(
|
||||||
child: (state, loading) => ListView.builder(
|
child: (state, loading) => ListView.builder(
|
||||||
itemCount: state.messageList.messages.length,
|
itemCount: state.messageList.messages.length,
|
||||||
|
|||||||
@@ -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<MarianumMessage?> {
|
||||||
|
final String base;
|
||||||
|
final List<MarianumMessage> messages;
|
||||||
|
|
||||||
|
SearchMarianumMessages({required this.base, required this.messages});
|
||||||
|
|
||||||
|
List<MarianumMessage> _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<Widget>? 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);
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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/chat/get_chat_response.dart';
|
||||||
import '../../../api/marianumcloud/talk/room/get_room_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 '../../../theming/app_theme.dart';
|
||||||
import '../../../widget/clickable_app_bar.dart';
|
import '../../../widget/clickable_app_bar.dart';
|
||||||
import '../../../widget/user_avatar.dart';
|
import '../../../widget/user_avatar.dart';
|
||||||
|
import 'data/chat_search_controller.dart';
|
||||||
import 'details/chat_info.dart';
|
import 'details/chat_info.dart';
|
||||||
import 'talk_navigator.dart';
|
import 'talk_navigator.dart';
|
||||||
import 'widgets/chat_bubble.dart';
|
import 'widgets/chat_bubble.dart';
|
||||||
|
import 'widgets/chat_search_app_bar.dart';
|
||||||
import 'widgets/chat_textfield.dart';
|
import 'widgets/chat_textfield.dart';
|
||||||
|
|
||||||
class ChatView extends StatefulWidget {
|
class ChatView extends StatefulWidget {
|
||||||
@@ -32,14 +37,139 @@ class ChatView extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ChatViewState extends State<ChatView> {
|
class _ChatViewState extends State<ChatView> {
|
||||||
final ScrollController _listController = ScrollController();
|
final ItemScrollController _itemScrollController = ItemScrollController();
|
||||||
|
final TextEditingController _searchTextController = TextEditingController();
|
||||||
|
final Map<int, int> _matchIndices = {};
|
||||||
|
|
||||||
|
bool _searchActive = false;
|
||||||
|
String _searchQuery = '';
|
||||||
|
List<ChatSearchMatch> _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() {
|
void _refresh() {
|
||||||
context.read<ChatBloc>().setToken(widget.room.token);
|
context.read<ChatBloc>().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<ChatBloc>().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<Widget> _buildMessages(GetChatResponse response) {
|
List<Widget> _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 = <Widget>[];
|
final messages = <Widget>[];
|
||||||
|
final chronologicalMatchIndex = <int, int>{};
|
||||||
var lastDate = DateTime.now();
|
var lastDate = DateTime.now();
|
||||||
for (final element in response.sortByTimestamp()) {
|
for (final element in response.sortByTimestamp()) {
|
||||||
final elementDate = DateTime.fromMillisecondsSinceEpoch(
|
final elementDate = DateTime.fromMillisecondsSinceEpoch(
|
||||||
@@ -48,6 +178,7 @@ class _ChatViewState extends State<ChatView> {
|
|||||||
|
|
||||||
if (element.systemMessage.contains('reaction')) continue;
|
if (element.systemMessage.contains('reaction')) continue;
|
||||||
if (element.systemMessage.contains('poll_voted')) continue;
|
if (element.systemMessage.contains('poll_voted')) continue;
|
||||||
|
if (element.systemMessage.contains('message_deleted')) continue;
|
||||||
final commonRead = int.parse(
|
final commonRead = int.parse(
|
||||||
response.headers?['x-chat-last-common-read'] ?? '0',
|
response.headers?['x-chat-last-common-read'] ?? '0',
|
||||||
);
|
);
|
||||||
@@ -65,17 +196,31 @@ class _ChatViewState extends State<ChatView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
messages.add(
|
||||||
ChatBubble(
|
ChatBubble(
|
||||||
context: context,
|
context: context,
|
||||||
isSender:
|
isSender:
|
||||||
element.actorId == widget.selfId &&
|
element.actorId == widget.selfId &&
|
||||||
element.messageType == GetRoomResponseObjectMessageType.comment,
|
(element.messageType ==
|
||||||
|
GetRoomResponseObjectMessageType.comment ||
|
||||||
|
element.messageType ==
|
||||||
|
GetRoomResponseObjectMessageType.deletedComment),
|
||||||
bubbleData: element,
|
bubbleData: element,
|
||||||
chatData: widget.room,
|
chatData: widget.room,
|
||||||
refetch: ({bool renew = false}) => _refresh(),
|
refetch: ({bool renew = false}) => _refresh(),
|
||||||
isRead: element.id <= commonRead,
|
isRead: element.id <= commonRead,
|
||||||
selfId: widget.selfId,
|
selfId: widget.selfId,
|
||||||
|
highlightQuery: highlightQuery,
|
||||||
|
matchHighlight: highlight,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -94,16 +239,37 @@ class _ChatViewState extends State<ChatView> {
|
|||||||
refetch: ({bool renew = false}) => _refresh(),
|
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;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Scaffold(
|
Widget build(BuildContext context) => Scaffold(
|
||||||
backgroundColor: const Color(0xffefeae2),
|
backgroundColor: const Color(0xffefeae2),
|
||||||
appBar: ClickableAppBar(
|
appBar: _searchActive
|
||||||
onTap: () => TalkNavigator.pushSplitView(context, ChatInfo(widget.room)),
|
? 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(
|
appBar: AppBar(
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -118,6 +284,13 @@ class _ChatViewState extends State<ChatView> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.search),
|
||||||
|
tooltip: 'In Chat suchen',
|
||||||
|
onPressed: _enterSearchMode,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: DecoratedBox(
|
body: DecoratedBox(
|
||||||
@@ -137,11 +310,16 @@ class _ChatViewState extends State<ChatView> {
|
|||||||
isReady: (state) =>
|
isReady: (state) =>
|
||||||
state.chatResponse != null &&
|
state.chatResponse != null &&
|
||||||
state.currentToken == widget.room.token,
|
state.currentToken == widget.room.token,
|
||||||
child: (state, _) => ListView(
|
child: (state, _) {
|
||||||
|
final items =
|
||||||
|
_buildMessages(state.chatResponse!).reversed.toList();
|
||||||
|
return ScrollablePositionedList.builder(
|
||||||
reverse: true,
|
reverse: true,
|
||||||
controller: _listController,
|
itemScrollController: _itemScrollController,
|
||||||
children: _buildMessages(state.chatResponse!).reversed.toList(),
|
itemCount: items.length,
|
||||||
),
|
itemBuilder: (ctx, idx) => items[idx],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ColoredBox(
|
ColoredBox(
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.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/get_chat_response.dart';
|
||||||
import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart';
|
import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart';
|
||||||
import '../../../../model/account_data.dart';
|
import '../../../../model/account_data.dart';
|
||||||
import '../../../../model/endpoint_data.dart';
|
import '../../../../model/endpoint_data.dart';
|
||||||
import '../../../../utils/url_opener.dart';
|
import '../../../../utils/url_opener.dart';
|
||||||
|
import '../widgets/highlighted_linkify.dart';
|
||||||
|
|
||||||
class ChatMessage {
|
class ChatMessage {
|
||||||
String originalMessage;
|
String originalMessage;
|
||||||
@@ -27,8 +27,13 @@ class ChatMessage {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget getWidget() {
|
Widget getWidget({String? highlightQuery, TextStyle? style}) {
|
||||||
var contentWidget = Linkify(text: content, onOpen: UrlOpener.onOpen);
|
var contentWidget = HighlightedLinkify(
|
||||||
|
text: content,
|
||||||
|
onOpen: UrlOpener.onOpen,
|
||||||
|
highlight: highlightQuery,
|
||||||
|
style: style,
|
||||||
|
);
|
||||||
|
|
||||||
if (originalData?['object']?.type == RichObjectStringObjectType.talkPoll) {
|
if (originalData?['object']?.type == RichObjectStringObjectType.talkPoll) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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<ChatSearchMatch> findMatches(
|
||||||
|
GetChatResponse response,
|
||||||
|
String query,
|
||||||
|
) {
|
||||||
|
final q = query.trim().toLowerCase();
|
||||||
|
if (q.isEmpty) return const [];
|
||||||
|
|
||||||
|
final matches = <ChatSearchMatch>[];
|
||||||
|
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,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ class BubbleStyle {
|
|||||||
const BubbleStyle({
|
const BubbleStyle({
|
||||||
this.color,
|
this.color,
|
||||||
this.borderWidth = 0,
|
this.borderWidth = 0,
|
||||||
|
this.borderColor,
|
||||||
this.elevation = 0,
|
this.elevation = 0,
|
||||||
this.margin = const BubbleEdges.only(),
|
this.margin = const BubbleEdges.only(),
|
||||||
this.padding = const BubbleEdges.all(8),
|
this.padding = const BubbleEdges.all(8),
|
||||||
@@ -37,12 +38,25 @@ class BubbleStyle {
|
|||||||
|
|
||||||
final Color? color;
|
final Color? color;
|
||||||
final double borderWidth;
|
final double borderWidth;
|
||||||
|
final Color? borderColor;
|
||||||
final double elevation;
|
final double elevation;
|
||||||
final BubbleEdges margin;
|
final BubbleEdges margin;
|
||||||
final BubbleEdges padding;
|
final BubbleEdges padding;
|
||||||
final Alignment alignment;
|
final Alignment alignment;
|
||||||
final BubbleNip nip;
|
final BubbleNip nip;
|
||||||
final double borderRadius;
|
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
|
/// The "nip" is faked by flattening one corner so the bubble anchors to
|
||||||
@@ -88,7 +102,7 @@ class Bubble extends StatelessWidget {
|
|||||||
borderRadius: radius,
|
borderRadius: radius,
|
||||||
border: style.borderWidth > 0
|
border: style.borderWidth > 0
|
||||||
? Border.all(
|
? Border.all(
|
||||||
color: Theme.of(context).dividerColor,
|
color: style.borderColor ?? Theme.of(context).dividerColor,
|
||||||
width: style.borderWidth,
|
width: style.borderWidth,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import 'bubble.dart';
|
|||||||
import 'chat_bubble_poll.dart';
|
import 'chat_bubble_poll.dart';
|
||||||
import 'chat_bubble_reactions.dart';
|
import 'chat_bubble_reactions.dart';
|
||||||
import 'chat_message_options_dialog.dart';
|
import 'chat_message_options_dialog.dart';
|
||||||
|
import 'highlighted_linkify.dart';
|
||||||
|
|
||||||
|
enum SearchHighlight { none, secondary, active }
|
||||||
|
|
||||||
class ChatBubble extends StatefulWidget {
|
class ChatBubble extends StatefulWidget {
|
||||||
final BuildContext context;
|
final BuildContext context;
|
||||||
@@ -33,6 +36,9 @@ class ChatBubble extends StatefulWidget {
|
|||||||
|
|
||||||
final void Function({bool renew}) refetch;
|
final void Function({bool renew}) refetch;
|
||||||
|
|
||||||
|
final String? highlightQuery;
|
||||||
|
final SearchHighlight matchHighlight;
|
||||||
|
|
||||||
const ChatBubble({
|
const ChatBubble({
|
||||||
required this.context,
|
required this.context,
|
||||||
required this.isSender,
|
required this.isSender,
|
||||||
@@ -41,6 +47,8 @@ class ChatBubble extends StatefulWidget {
|
|||||||
required this.refetch,
|
required this.refetch,
|
||||||
this.isRead = false,
|
this.isRead = false,
|
||||||
this.selfId,
|
this.selfId,
|
||||||
|
this.highlightQuery,
|
||||||
|
this.matchHighlight = SearchHighlight.none,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,16 +148,54 @@ class _ChatBubbleState extends State<ChatBubble>
|
|||||||
).asDialog(context);
|
).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() {
|
BubbleStyle _getStyle() {
|
||||||
final styles = ChatBubbleStyles(context);
|
final styles = ChatBubbleStyles(context);
|
||||||
if (widget.bubbleData.messageType !=
|
final BubbleStyle base;
|
||||||
GetRoomResponseObjectMessageType.comment) {
|
if (!_rendersAsCommentBubble) {
|
||||||
return styles.getSystemStyle();
|
base = styles.getSystemStyle();
|
||||||
}
|
} else {
|
||||||
return widget.isSender
|
base = widget.isSender
|
||||||
? styles.getSelfStyle(false)
|
? styles.getSelfStyle(false)
|
||||||
: styles.getRemoteStyle(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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _showOptionsDialog() => showChatMessageOptionsDialog(
|
void _showOptionsDialog() => showChatMessageOptionsDialog(
|
||||||
context,
|
context,
|
||||||
@@ -159,6 +205,18 @@ class _ChatBubbleState extends State<ChatBubble>
|
|||||||
onRefetch: widget.refetch,
|
onRefetch: widget.refetch,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// True only for messages whose body has a meaningful tap action (poll
|
||||||
|
/// dialog or file download/cancel). For plain text messages we leave
|
||||||
|
/// `onTap: null` on the bubble's `GestureDetector` so its
|
||||||
|
/// `TapGestureRecognizer` does not enter the gesture arena — otherwise
|
||||||
|
/// it competes with (and blocks) the per-link `TapGestureRecognizer`s
|
||||||
|
/// that `HighlightedLinkify` attaches to URL spans.
|
||||||
|
bool get _hasTapAction {
|
||||||
|
final obj = message.originalData?['object'];
|
||||||
|
if (obj?.type == RichObjectStringObjectType.talkPoll) return true;
|
||||||
|
return message.file != null;
|
||||||
|
}
|
||||||
|
|
||||||
void _onTap() {
|
void _onTap() {
|
||||||
final obj = message.originalData?['object'];
|
final obj = message.originalData?['object'];
|
||||||
if (obj?.type == RichObjectStringObjectType.talkPoll) {
|
if (obj?.type == RichObjectStringObjectType.talkPoll) {
|
||||||
@@ -186,25 +244,36 @@ class _ChatBubbleState extends State<ChatBubble>
|
|||||||
originalData: widget.bubbleData.messageParameters,
|
originalData: widget.bubbleData.messageParameters,
|
||||||
);
|
);
|
||||||
final showActorDisplayName =
|
final showActorDisplayName =
|
||||||
widget.bubbleData.messageType ==
|
_rendersAsCommentBubble &&
|
||||||
GetRoomResponseObjectMessageType.comment &&
|
|
||||||
widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne;
|
widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne;
|
||||||
final showBubbleTime =
|
final showBubbleTime =
|
||||||
widget.bubbleData.messageType !=
|
widget.bubbleData.messageType !=
|
||||||
GetRoomResponseObjectMessageType.system &&
|
GetRoomResponseObjectMessageType.system;
|
||||||
widget.bubbleData.messageType !=
|
|
||||||
GetRoomResponseObjectMessageType.deletedComment;
|
|
||||||
|
|
||||||
final parent = widget.bubbleData.parent;
|
final parent = widget.bubbleData.parent;
|
||||||
|
final actorBaseStyle = TextStyle(
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
);
|
||||||
final actorText = Text(
|
final actorText = Text(
|
||||||
widget.bubbleData.actorDisplayName,
|
widget.bubbleData.actorDisplayName,
|
||||||
textAlign: TextAlign.start,
|
textAlign: TextAlign.start,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: actorBaseStyle,
|
||||||
color: Theme.of(context).primaryColor,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
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(
|
final timeText = Text(
|
||||||
DateTime.fromMillisecondsSinceEpoch(
|
DateTime.fromMillisecondsSinceEpoch(
|
||||||
@@ -245,15 +314,19 @@ class _ChatBubbleState extends State<ChatBubble>
|
|||||||
},
|
},
|
||||||
onLongPress: _showOptionsDialog,
|
onLongPress: _showOptionsDialog,
|
||||||
onDoubleTap: _showOptionsDialog,
|
onDoubleTap: _showOptionsDialog,
|
||||||
onTap: _onTap,
|
onTap: _hasTapAction ? _onTap : null,
|
||||||
child: Transform.translate(
|
child: Transform.translate(
|
||||||
offset: _position,
|
offset: _position,
|
||||||
child: Bubble(
|
child: Bubble(
|
||||||
style: _getStyle(),
|
style: _getStyle(),
|
||||||
child: _BubbleContent(
|
child: _BubbleContent(
|
||||||
actorText: actorText,
|
actorText: actorText,
|
||||||
|
actorWidget: actorWidget,
|
||||||
timeText: timeText,
|
timeText: timeText,
|
||||||
messageWidget: message.getWidget(),
|
messageWidget: message.getWidget(
|
||||||
|
highlightQuery: widget.highlightQuery,
|
||||||
|
style: _messageTextStyle(context),
|
||||||
|
),
|
||||||
parent: parent,
|
parent: parent,
|
||||||
bubbleData: widget.bubbleData,
|
bubbleData: widget.bubbleData,
|
||||||
isSender: widget.isSender,
|
isSender: widget.isSender,
|
||||||
@@ -282,6 +355,7 @@ class _ChatBubbleState extends State<ChatBubble>
|
|||||||
|
|
||||||
class _BubbleContent extends StatelessWidget {
|
class _BubbleContent extends StatelessWidget {
|
||||||
final Text actorText;
|
final Text actorText;
|
||||||
|
final Widget actorWidget;
|
||||||
final Text timeText;
|
final Text timeText;
|
||||||
final Widget messageWidget;
|
final Widget messageWidget;
|
||||||
final GetChatResponseObject? parent;
|
final GetChatResponseObject? parent;
|
||||||
@@ -298,6 +372,7 @@ class _BubbleContent extends StatelessWidget {
|
|||||||
|
|
||||||
const _BubbleContent({
|
const _BubbleContent({
|
||||||
required this.actorText,
|
required this.actorText,
|
||||||
|
required this.actorWidget,
|
||||||
required this.timeText,
|
required this.timeText,
|
||||||
required this.messageWidget,
|
required this.messageWidget,
|
||||||
required this.parent,
|
required this.parent,
|
||||||
@@ -323,7 +398,7 @@ class _BubbleContent extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
if (showActorDisplayName) Positioned(top: 0, left: 0, child: actorText),
|
if (showActorDisplayName) Positioned(top: 0, left: 0, child: actorWidget),
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
bottom: showBubbleTime ? 18 : 0,
|
bottom: showBubbleTime ? 18 : 0,
|
||||||
|
|||||||
@@ -140,12 +140,22 @@ void showChatMessageOptionsDialog(
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (canDelete)
|
if (canDelete)
|
||||||
AsyncListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.delete_outline),
|
leading: const Icon(Icons.delete_outline),
|
||||||
title: const Text('Nachricht löschen'),
|
title: const Text('Nachricht löschen'),
|
||||||
onPressed: () async {
|
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();
|
await DeleteMessage(chatData.token, bubbleData.id).run();
|
||||||
if (sheetCtx.mounted) sheetCtx.read<ChatBloc>().refresh();
|
if (!sheetCtx.mounted) return;
|
||||||
|
final bloc = sheetCtx.read<ChatBloc>();
|
||||||
|
Navigator.of(sheetCtx).pop();
|
||||||
|
bloc.refresh();
|
||||||
|
},
|
||||||
|
).asDialog(sheetCtx);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
DebugTile(sheetCtx).jsonData(bubbleData.toJson()),
|
DebugTile(sheetCtx).jsonData(bubbleData.toJson()),
|
||||||
|
|||||||
@@ -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<String> 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
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<TextSpan> 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 = <TextSpan>[];
|
||||||
|
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<HighlightedLinkify> createState() => _HighlightedLinkifyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HighlightedLinkifyState extends State<HighlightedLinkify> {
|
||||||
|
final List<TapGestureRecognizer> _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;
|
||||||
|
// Start from the surrounding text style so links inherit font family,
|
||||||
|
// size, weight, etc., then layer the link-specific color and underline
|
||||||
|
// on top. (Going the other way around — link style as base — used to
|
||||||
|
// work because TextStyle.copyWith treats `null` as "leave unchanged",
|
||||||
|
// so the explicit `color: null, decoration: null` were silently
|
||||||
|
// ignored and the merge pulled defaultStyle's color/decoration over
|
||||||
|
// the blue + underline. Result: links rendered in body-text color
|
||||||
|
// with no underline.)
|
||||||
|
final linkStyle = defaultStyle.merge(
|
||||||
|
widget.linkStyle ??
|
||||||
|
const TextStyle(
|
||||||
|
color: Colors.blue,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const linkHighlight = TextStyle(
|
||||||
|
backgroundColor: Color(0xFFFFD54F),
|
||||||
|
color: Colors.black,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
);
|
||||||
|
|
||||||
|
final elements = linkify_pkg.linkify(widget.text);
|
||||||
|
final spans = <InlineSpan>[];
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+633
-78
@@ -1,20 +1,25 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:chewie/chewie.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:open_filex/open_filex.dart';
|
import 'package:open_filex/open_filex.dart';
|
||||||
import 'package:photo_view/photo_view.dart';
|
import 'package:photo_view/photo_view.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||||||
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
import '../routing/app_routes.dart';
|
import '../routing/app_routes.dart';
|
||||||
import '../share_intent/remote_file_ref.dart';
|
import '../share_intent/remote_file_ref.dart';
|
||||||
import '../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
|
import 'app_progress_indicator.dart';
|
||||||
|
import 'centered_leading.dart';
|
||||||
import 'info_dialog.dart';
|
import 'info_dialog.dart';
|
||||||
import 'placeholder_view.dart';
|
|
||||||
import 'share_position_origin.dart';
|
import 'share_position_origin.dart';
|
||||||
|
|
||||||
class FileViewer extends StatefulWidget {
|
class FileViewer extends StatefulWidget {
|
||||||
@@ -39,6 +44,88 @@ class FileViewer extends StatefulWidget {
|
|||||||
|
|
||||||
enum FileViewingActions { openExternal, share, save, sendToChat, saveToCloud }
|
enum FileViewingActions { openExternal, share, save, sendToChat, saveToCloud }
|
||||||
|
|
||||||
|
enum _FileKind { image, svg, pdf, text, video, audio, unknown }
|
||||||
|
|
||||||
|
const Set<String> _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<String> _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<String> _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<String> _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<bool> _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
|
/// Workaround for a Syncfusion PDF viewer race: SfPdfViewer's internal
|
||||||
/// LayoutBuilder calls `localToGlobal` during build, which asserts when an
|
/// LayoutBuilder calls `localToGlobal` during build, which asserts when an
|
||||||
/// ancestor RenderTransform (from the page-push animation) is still mid-layout.
|
/// ancestor RenderTransform (from the page-push animation) is still mid-layout.
|
||||||
@@ -82,7 +169,7 @@ class _DeferredPdfViewerState extends State<_DeferredPdfViewer> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (!_ready) {
|
if (!_ready) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: AppProgressIndicator.large());
|
||||||
}
|
}
|
||||||
return SfPdfViewer.file(File(widget.path));
|
return SfPdfViewer.file(File(widget.path));
|
||||||
}
|
}
|
||||||
@@ -93,13 +180,32 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
|
|
||||||
late SettingsCubit settings = context.read<SettingsCubit>();
|
late SettingsCubit settings = context.read<SettingsCubit>();
|
||||||
late bool openExternal;
|
late bool openExternal;
|
||||||
|
Future<_FileKind>? _fileKind;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
super.initState();
|
||||||
openExternal =
|
openExternal =
|
||||||
settings.val().fileViewSettings.alwaysOpenExternally ||
|
settings.val().fileViewSettings.alwaysOpenExternally ||
|
||||||
widget.openExternal;
|
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<void> _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
|
@override
|
||||||
@@ -108,14 +214,19 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
Future<_FileKind> _detectKind() async {
|
||||||
Widget build(BuildContext context) {
|
final ext = widget.path.split('.').last.toLowerCase();
|
||||||
AppBar appbar({List<Widget> actions = const []}) => AppBar(
|
if (_imageExtensions.contains(ext)) return _FileKind.image;
|
||||||
title: Text(widget.path.split('/').last),
|
if (ext == 'svg') return _FileKind.svg;
|
||||||
actions: [
|
if (ext == 'pdf') return _FileKind.pdf;
|
||||||
...actions,
|
if (_videoExtensions.contains(ext)) return _FileKind.video;
|
||||||
PopupMenuButton<FileViewingActions>(
|
if (_audioExtensions.contains(ext)) return _FileKind.audio;
|
||||||
onSelected: (value) async {
|
if (_textExtensions.contains(ext)) return _FileKind.text;
|
||||||
|
if (await _looksLikeText(widget.path)) return _FileKind.text;
|
||||||
|
return _FileKind.unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleAction(FileViewingActions value) async {
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case FileViewingActions.openExternal:
|
case FileViewingActions.openExternal:
|
||||||
AppRoutes.openFileViewer(
|
AppRoutes.openFileViewer(
|
||||||
@@ -129,10 +240,7 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
AppRoutes.openInternalShareToChat(context, widget.remoteFile!);
|
AppRoutes.openInternalShareToChat(context, widget.remoteFile!);
|
||||||
break;
|
break;
|
||||||
case FileViewingActions.saveToCloud:
|
case FileViewingActions.saveToCloud:
|
||||||
AppRoutes.openInternalSaveToFolder(
|
AppRoutes.openInternalSaveToFolder(context, widget.remoteFile!);
|
||||||
context,
|
|
||||||
widget.remoteFile!,
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
case FileViewingActions.share:
|
case FileViewingActions.share:
|
||||||
unawaited(
|
unawaited(
|
||||||
@@ -151,12 +259,12 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
fileName: widget.path.split('/').last,
|
fileName: widget.path.split('/').last,
|
||||||
bytes: bytes,
|
bytes: bytes,
|
||||||
);
|
);
|
||||||
if (!context.mounted) return;
|
if (!mounted) return;
|
||||||
if (saved != null) {
|
if (saved != null) {
|
||||||
InfoDialog.show(context, 'Datei gespeichert.');
|
InfoDialog.show(context, 'Datei gespeichert.');
|
||||||
}
|
}
|
||||||
} on Object catch (e) {
|
} on Object catch (e) {
|
||||||
if (!context.mounted) return;
|
if (!mounted) return;
|
||||||
InfoDialog.show(
|
InfoDialog.show(
|
||||||
context,
|
context,
|
||||||
'Speichern fehlgeschlagen: $e',
|
'Speichern fehlgeschlagen: $e',
|
||||||
@@ -166,63 +274,105 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
itemBuilder: (context) => <PopupMenuEntry<FileViewingActions>>[
|
|
||||||
const PopupMenuItem(
|
List<_ActionDescriptor> _availableActions() => [
|
||||||
value: FileViewingActions.openExternal,
|
_ActionDescriptor(
|
||||||
child: ListTile(
|
action: FileViewingActions.openExternal,
|
||||||
leading: Icon(Icons.open_in_new),
|
// iOS opens the system share sheet (square-with-arrow icon), Android
|
||||||
title: Text('Extern öffnen'),
|
// the standard app picker; mirror that visually and verbally.
|
||||||
dense: true,
|
icon: Platform.isIOS ? Icons.ios_share : Icons.open_in_new,
|
||||||
),
|
label: Platform.isIOS ? 'Extern öffnen' : 'Öffnen mit',
|
||||||
),
|
),
|
||||||
if (widget.remoteFile != null) ...[
|
if (widget.remoteFile != null) ...[
|
||||||
const PopupMenuItem(
|
const _ActionDescriptor(
|
||||||
value: FileViewingActions.sendToChat,
|
action: FileViewingActions.sendToChat,
|
||||||
child: ListTile(
|
icon: Icons.chat_bubble_outline,
|
||||||
leading: Icon(Icons.chat_bubble_outline),
|
label: 'An Talk-Chat senden',
|
||||||
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 _ActionDescriptor(
|
||||||
|
action: FileViewingActions.saveToCloud,
|
||||||
|
icon: Icons.cloud_outlined,
|
||||||
|
label: 'In Cloud speichern',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const PopupMenuItem(
|
const _ActionDescriptor(
|
||||||
value: FileViewingActions.share,
|
action: FileViewingActions.share,
|
||||||
|
icon: Icons.share_outlined,
|
||||||
|
label: 'Teilen',
|
||||||
|
),
|
||||||
|
const _ActionDescriptor(
|
||||||
|
action: FileViewingActions.save,
|
||||||
|
icon: Icons.save_alt_outlined,
|
||||||
|
label: 'Speichern',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
AppBar _appbar({
|
||||||
|
List<Widget> actions = const [],
|
||||||
|
bool showActionsMenu = true,
|
||||||
|
}) => AppBar(
|
||||||
|
title: Text(widget.path.split('/').last),
|
||||||
|
actions: [
|
||||||
|
...actions,
|
||||||
|
if (showActionsMenu)
|
||||||
|
PopupMenuButton<FileViewingActions>(
|
||||||
|
onSelected: _handleAction,
|
||||||
|
itemBuilder: (context) => _availableActions()
|
||||||
|
.map(
|
||||||
|
(a) => PopupMenuItem(
|
||||||
|
value: a.action,
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: Icon(Icons.share_outlined),
|
leading: Icon(a.icon),
|
||||||
title: Text('Teilen'),
|
title: Text(a.label),
|
||||||
dense: true,
|
dense: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const PopupMenuItem(
|
)
|
||||||
value: FileViewingActions.save,
|
.toList(),
|
||||||
child: ListTile(
|
|
||||||
leading: Icon(Icons.save_alt_outlined),
|
|
||||||
title: Text('Speichern'),
|
|
||||||
dense: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
switch (openExternal ? '' : widget.path.split('.').last.toLowerCase()) {
|
@override
|
||||||
case 'png':
|
Widget build(BuildContext context) {
|
||||||
case 'jpg':
|
if (openExternal) {
|
||||||
case 'jpeg':
|
|
||||||
case 'webp':
|
|
||||||
case 'gif':
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: appbar(
|
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: [
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -246,29 +396,434 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'pdf':
|
Widget _buildSvgView() => Scaffold(
|
||||||
return Scaffold(
|
appBar: _appbar(),
|
||||||
appBar: appbar(),
|
backgroundColor: Colors.white,
|
||||||
body: _DeferredPdfViewer(path: widget.path),
|
body: InteractiveViewer(
|
||||||
|
minScale: 0.5,
|
||||||
|
maxScale: 8,
|
||||||
|
child: Center(
|
||||||
|
child: SvgPicture.file(
|
||||||
|
File(widget.path),
|
||||||
|
placeholderBuilder: (_) =>
|
||||||
|
const Center(child: AppProgressIndicator.large()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
default:
|
Widget _buildPdfView() =>
|
||||||
OpenFilex.open(widget.path).then((result) {
|
Scaffold(appBar: _appbar(), body: _DeferredPdfViewer(path: widget.path));
|
||||||
if (!context.mounted) return;
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
if (result.type != ResultType.done) {
|
|
||||||
InfoDialog.show(context, result.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return PlaceholderView(
|
Widget _buildVideoView() => Scaffold(
|
||||||
text: 'Datei extern geöffnet',
|
appBar: _appbar(),
|
||||||
icon: Icons.open_in_new,
|
backgroundColor: Colors.black,
|
||||||
button: TextButton(
|
body: _MediaPlayer(path: widget.path, isAudio: false),
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
);
|
||||||
child: const Text('Zurück'),
|
|
||||||
|
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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
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<void> _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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ dependencies:
|
|||||||
workmanager: ^0.9.0+3
|
workmanager: ^0.9.0+3
|
||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
flutter_linkify: ^6.0.0
|
flutter_linkify: ^6.0.0
|
||||||
|
linkify: ^5.0.0
|
||||||
flutter_local_notifications: ^21.0.0
|
flutter_local_notifications: ^21.0.0
|
||||||
|
scrollable_positioned_list: ^0.3.8
|
||||||
flutter_split_view: ^0.1.2
|
flutter_split_view: ^0.1.2
|
||||||
flutter_svg: ^2.0.10
|
flutter_svg: ^2.0.10
|
||||||
freezed_annotation: ^3.1.0
|
freezed_annotation: ^3.1.0
|
||||||
@@ -72,6 +74,8 @@ dependencies:
|
|||||||
url_launcher: ^6.3.1
|
url_launcher: ^6.3.1
|
||||||
enough_icalendar: ^0.17.0
|
enough_icalendar: ^0.17.0
|
||||||
receive_sharing_intent: ^1.8.1
|
receive_sharing_intent: ^1.8.1
|
||||||
|
video_player: ^2.9.0
|
||||||
|
chewie: ^1.8.5
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -58,4 +58,53 @@ void main() {
|
|||||||
expect(dt.timeRangeTo(end), '09:07 - 09:52');
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String, dynamic> _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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
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<String, RichObjectString>? params,
|
||||||
|
}) =>
|
||||||
|
GetChatResponseObject(
|
||||||
|
id,
|
||||||
|
'token',
|
||||||
|
GetRoomResponseObjectMessageActorType.user,
|
||||||
|
'actor-id',
|
||||||
|
actorDisplayName,
|
||||||
|
timestamp,
|
||||||
|
systemMessage,
|
||||||
|
type,
|
||||||
|
true,
|
||||||
|
'',
|
||||||
|
message,
|
||||||
|
params,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
GetChatResponse _response(List<GetChatResponseObject> 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, 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([
|
||||||
|
_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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user