simplified code across the app and consolidated duplicated helpers
This commit is contained in:
@@ -30,6 +30,12 @@ class _AbsenceReportViewState extends State<AbsenceReportView> {
|
||||
final TextEditingController _lastName = TextEditingController();
|
||||
final TextEditingController _phone = TextEditingController();
|
||||
final TextEditingController _note = TextEditingController();
|
||||
late final List<TextEditingController> _textControllers = [
|
||||
_firstName,
|
||||
_lastName,
|
||||
_phone,
|
||||
_note,
|
||||
];
|
||||
final AsyncActionController _submitController = AsyncActionController();
|
||||
|
||||
late Future<void> _init;
|
||||
@@ -46,7 +52,7 @@ class _AbsenceReportViewState extends State<AbsenceReportView> {
|
||||
final today = DateUtils.dateOnly(DateTime.now());
|
||||
_absentFrom = today;
|
||||
_absentUntil = today;
|
||||
for (final c in [_firstName, _lastName, _phone, _note]) {
|
||||
for (final c in _textControllers) {
|
||||
c.addListener(_onFieldChanged);
|
||||
}
|
||||
_init = _load();
|
||||
@@ -54,7 +60,7 @@ class _AbsenceReportViewState extends State<AbsenceReportView> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final c in [_firstName, _lastName, _phone, _note]) {
|
||||
for (final c in _textControllers) {
|
||||
c.dispose();
|
||||
}
|
||||
_submitController.dispose();
|
||||
@@ -290,13 +296,11 @@ class _AbsenceReportViewState extends State<AbsenceReportView> {
|
||||
String? _requiredError(TextEditingController controller) =>
|
||||
_submitted && controller.text.trim().isEmpty ? _required : null;
|
||||
|
||||
InputDecoration _decoration(String label, {String? error, String? hint}) =>
|
||||
InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: label,
|
||||
hintText: hint,
|
||||
errorText: error,
|
||||
);
|
||||
InputDecoration _decoration(String label, {String? error}) => InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: label,
|
||||
errorText: error,
|
||||
);
|
||||
}
|
||||
|
||||
/// Terminal success screen shown in place of the form after a report was
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
|
||||
import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import 'sort_options.dart';
|
||||
|
||||
typedef FileSort = ({SortOption by, bool ascending, bool foldersToTop});
|
||||
|
||||
/// Selector for `context.select`, so folder views rebuild only when the
|
||||
/// file sort settings change.
|
||||
FileSort fileSortOf(SettingsCubit cubit) {
|
||||
final settings = cubit.state.fileSettings;
|
||||
return (
|
||||
by: settings.sortBy,
|
||||
ascending: settings.ascending,
|
||||
foldersToTop: settings.sortFoldersToTop,
|
||||
);
|
||||
}
|
||||
|
||||
extension FileSortListing on ListFilesResponse {
|
||||
List<CacheableFile> sortedBy(FileSort sort) => sortBy(
|
||||
sortOption: sort.by,
|
||||
foldersToTop: sort.foldersToTop,
|
||||
reversed: sort.ascending,
|
||||
);
|
||||
}
|
||||
@@ -11,12 +11,7 @@ IconData iconForFile(CacheableFile file) {
|
||||
final byMime = _iconForMime(mime);
|
||||
if (byMime != null) return byMime;
|
||||
}
|
||||
final ext = _extensionOf(file.name);
|
||||
if (ext != null) {
|
||||
final byExt = _extensionIcons[ext];
|
||||
if (byExt != null) return byExt;
|
||||
}
|
||||
return Icons.insert_drive_file_outlined;
|
||||
return iconForFileName(file.name);
|
||||
}
|
||||
|
||||
/// Icon for a bare file name (no [CacheableFile] / MIME available), e.g. in the
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Paths relative to the WebDAV root, as used by `CacheableFile.path`: no
|
||||
// leading slash, folders end with '/', the root folder is ''.
|
||||
|
||||
final RegExp _edgeSlashes = RegExp(r'^/+|/+$');
|
||||
|
||||
List<String> webdavSegmentsOf(String path) {
|
||||
final stripped = path.replaceAll(_edgeSlashes, '');
|
||||
return stripped.isEmpty ? [] : stripped.split('/');
|
||||
}
|
||||
|
||||
List<String> webdavParentSegmentsOf(String path) {
|
||||
final segments = webdavSegmentsOf(path);
|
||||
if (segments.isNotEmpty) segments.removeLast();
|
||||
return segments;
|
||||
}
|
||||
|
||||
String webdavFolderPath(List<String> segments) =>
|
||||
segments.isEmpty ? '' : '${segments.join('/')}/';
|
||||
|
||||
String webdavJoin(String folder, String name, {required bool isDirectory}) =>
|
||||
isDirectory ? '$folder$name/' : '$folder$name';
|
||||
|
||||
/// Folder key used by `ListFilesCache` and `FilesBloc`: no leading or
|
||||
/// trailing slash, root is '/'.
|
||||
String listFilesCacheKey(List<String> segments) =>
|
||||
segments.isEmpty ? '/' : segments.join('/');
|
||||
@@ -11,10 +11,10 @@ import '../../../state/app/infrastructure/loadable_state/view/loadable_state_con
|
||||
import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
|
||||
import '../../../state/app/modules/files/bloc/files_bloc.dart';
|
||||
import '../../../state/app/modules/files/bloc/files_state.dart';
|
||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../utils/cache_invalidation_bus.dart';
|
||||
import '../../../widget/placeholder_view.dart';
|
||||
import 'data/sort_options.dart';
|
||||
import 'data/file_sort.dart';
|
||||
import 'data/webdav_path.dart';
|
||||
import 'files_upload_dialog.dart';
|
||||
import 'search/files_search_delegate.dart';
|
||||
import 'widgets/add_file_menu.dart';
|
||||
@@ -44,9 +44,6 @@ class _FilesView extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _FilesViewState extends State<_FilesView> {
|
||||
late final SettingsCubit settings;
|
||||
late SortOption currentSort;
|
||||
late bool currentSortDirection;
|
||||
late final StreamSubscription<String> _invalidationSub;
|
||||
|
||||
// The list builder also runs for loading flips and parent rebuilds; only a
|
||||
@@ -54,32 +51,16 @@ class _FilesViewState extends State<_FilesView> {
|
||||
Object? _sortedKey;
|
||||
List<CacheableFile> _sortedFiles = const [];
|
||||
|
||||
List<CacheableFile> _sorted(ListFilesResponse listing, bool foldersToTop) {
|
||||
final key = (listing, currentSort, currentSortDirection, foldersToTop);
|
||||
List<CacheableFile> _sorted(ListFilesResponse listing, FileSort sort) {
|
||||
final key = (listing, sort);
|
||||
if (key == _sortedKey) return _sortedFiles;
|
||||
_sortedKey = key;
|
||||
return _sortedFiles = listing.sortBy(
|
||||
sortOption: currentSort,
|
||||
foldersToTop: foldersToTop,
|
||||
reversed: currentSortDirection,
|
||||
);
|
||||
return _sortedFiles = listing.sortedBy(sort);
|
||||
}
|
||||
|
||||
// Cache key in FilesBloc's pathString format: '/' for root, otherwise
|
||||
// segments joined without leading/trailing slash.
|
||||
String get _myPathString => widget.path.isEmpty ? '/' : widget.path.join('/');
|
||||
|
||||
// Relative folder path matching the WebDAV format used by `CacheableFile.path`
|
||||
// (no leading slash; trailing slash for non-root). Empty string means root.
|
||||
String get _currentFolderPath =>
|
||||
widget.path.isEmpty ? '' : '${widget.path.join('/')}/';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
settings = context.read<SettingsCubit>();
|
||||
currentSort = settings.val().fileSettings.sortBy;
|
||||
currentSortDirection = settings.val().fileSettings.ascending;
|
||||
_invalidationSub = CacheInvalidationBus.listFilesStream.listen(
|
||||
_onInvalidation,
|
||||
);
|
||||
@@ -87,7 +68,7 @@ class _FilesViewState extends State<_FilesView> {
|
||||
|
||||
void _onInvalidation(String invalidatedPath) {
|
||||
if (!mounted) return;
|
||||
if (invalidatedPath != _myPathString) return;
|
||||
if (invalidatedPath != listFilesCacheKey(widget.path)) return;
|
||||
context.read<FilesBloc>().refresh();
|
||||
}
|
||||
|
||||
@@ -118,29 +99,12 @@ class _FilesViewState extends State<_FilesView> {
|
||||
final bloc = context.read<FilesBloc>();
|
||||
// Selected here, not in the LoadableStateConsumer child: that closure runs
|
||||
// during the consumer's build, where this context may not subscribe.
|
||||
final foldersToTop = context.select(
|
||||
(SettingsCubit c) => c.state.fileSettings.sortFoldersToTop,
|
||||
);
|
||||
final sort = context.select(fileSortOf);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.path.isNotEmpty ? widget.path.last : 'Dateien'),
|
||||
actions: [
|
||||
FilesSortActions(
|
||||
currentSort: currentSort,
|
||||
ascending: currentSortDirection,
|
||||
onDirectionChanged: (e) {
|
||||
setState(() {
|
||||
currentSortDirection = e;
|
||||
settings.val(write: true).fileSettings.ascending = e;
|
||||
});
|
||||
},
|
||||
onSortChanged: (e) {
|
||||
setState(() {
|
||||
currentSort = e;
|
||||
settings.val(write: true).fileSettings.sortBy = e;
|
||||
});
|
||||
},
|
||||
),
|
||||
const FilesSortActions(),
|
||||
IconButton(
|
||||
tooltip: 'Suchen',
|
||||
icon: const Icon(Icons.search),
|
||||
@@ -162,7 +126,7 @@ class _FilesViewState extends State<_FilesView> {
|
||||
body: Column(
|
||||
children: [
|
||||
ClipboardBanner(
|
||||
currentFolder: _currentFolderPath,
|
||||
currentFolder: webdavFolderPath(widget.path),
|
||||
onPasteDone: bloc.refresh,
|
||||
),
|
||||
Expanded(
|
||||
@@ -176,7 +140,7 @@ class _FilesViewState extends State<_FilesView> {
|
||||
text: 'Der Ordner ist leer',
|
||||
);
|
||||
}
|
||||
final files = _sorted(listing, foldersToTop);
|
||||
final files = _sorted(listing, sort);
|
||||
return ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: files.length,
|
||||
|
||||
@@ -28,13 +28,15 @@ class FilesUploadDialog extends StatefulWidget {
|
||||
}
|
||||
|
||||
class UploadableFile {
|
||||
TextEditingController fileNameController = TextEditingController();
|
||||
String filePath;
|
||||
String fileName;
|
||||
final String filePath;
|
||||
final TextEditingController fileNameController;
|
||||
double? _uploadProgress;
|
||||
bool isConflicting = false;
|
||||
|
||||
UploadableFile(this.filePath, this.fileName);
|
||||
UploadableFile(this.filePath, String fileName)
|
||||
: fileNameController = TextEditingController(text: fileName);
|
||||
|
||||
String get fileName => fileNameController.text;
|
||||
}
|
||||
|
||||
class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
@@ -47,10 +49,22 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_uploadableFiles = widget.filePaths.map((filePath) {
|
||||
var fileName = filePath.split(Platform.pathSeparator).last;
|
||||
return UploadableFile(filePath, fileName);
|
||||
}).toList();
|
||||
_uploadableFiles = widget.filePaths
|
||||
.map(
|
||||
(filePath) => UploadableFile(
|
||||
filePath,
|
||||
filePath.split(Platform.pathSeparator).last,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final file in _uploadableFiles) {
|
||||
file.fileNameController.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _resetProgress() {
|
||||
@@ -69,6 +83,10 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
);
|
||||
}
|
||||
|
||||
void _clearConflict(UploadableFile file) {
|
||||
if (file.isConflicting) setState(() => file.isConflicting = false);
|
||||
}
|
||||
|
||||
Future<void> uploadFiles({bool override = false}) async {
|
||||
setState(() {
|
||||
_isUploading = true;
|
||||
@@ -82,36 +100,39 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
if (!mounted) return;
|
||||
|
||||
if (!override) {
|
||||
List<dynamic> result;
|
||||
final List<String> existingPaths;
|
||||
try {
|
||||
result = (await webdavClient.propfind(
|
||||
final result = await webdavClient.propfind(
|
||||
PathUri.parse(widget.remotePath),
|
||||
)).responses;
|
||||
);
|
||||
existingPaths = [
|
||||
for (final response in result.responses)
|
||||
Uri.decodeComponent(response.href!),
|
||||
];
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_showUploadError('Verbindung fehlgeschlagen: $e');
|
||||
return;
|
||||
}
|
||||
final conflictingFiles = _uploadableFiles.where((file) {
|
||||
final fileName = file.fileName;
|
||||
return result.any(
|
||||
(element) => Uri.decodeComponent(
|
||||
(element as WebDavResponse).href!,
|
||||
).endsWith('/$fileName'),
|
||||
);
|
||||
}).toList();
|
||||
final conflictingFiles = _uploadableFiles
|
||||
.where(
|
||||
(file) => existingPaths.any(
|
||||
(path) => path.endsWith('/${file.fileName}'),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (conflictingFiles.isNotEmpty) {
|
||||
if (!mounted) return;
|
||||
final replaceFiles = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
contentPadding: const EdgeInsets.all(10),
|
||||
title: const Text('Konflikt', textAlign: TextAlign.center),
|
||||
content: conflictingFiles.length == 1
|
||||
? Text(
|
||||
'Eine Datei mit dem Namen "${conflictingFiles.map((e) => e.fileName).first}" existiert bereits.',
|
||||
'Eine Datei mit dem Namen "${conflictingFiles.first.fileName}" existiert bereits.',
|
||||
textAlign: TextAlign.left,
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
@@ -122,27 +143,20 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context, false);
|
||||
},
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Bearbeiten', textAlign: TextAlign.center),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ConfirmDialog(
|
||||
title: 'Bestätigen?',
|
||||
content:
|
||||
'Bist du sicher, dass du ${conflictingFiles.length} Dateien überschreiben möchtest?',
|
||||
onConfirm: () {
|
||||
Navigator.pop(context, true);
|
||||
},
|
||||
confirmButton: 'Ja',
|
||||
cancelButton: 'Nein',
|
||||
),
|
||||
);
|
||||
},
|
||||
// ConfirmDialog closes itself first, so this pop closes the
|
||||
// conflict dialog.
|
||||
onPressed: () => ConfirmDialog(
|
||||
title: 'Bestätigen?',
|
||||
content:
|
||||
'Bist du sicher, dass du ${conflictingFiles.length} Dateien überschreiben möchtest?',
|
||||
onConfirm: () => Navigator.pop(dialogContext, true),
|
||||
confirmButton: 'Ja',
|
||||
cancelButton: 'Nein',
|
||||
).asDialog(dialogContext),
|
||||
child: const Text('Überschreiben', textAlign: TextAlign.center),
|
||||
),
|
||||
],
|
||||
@@ -163,9 +177,9 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
}
|
||||
|
||||
var uploadetFilePaths = <String>[];
|
||||
for (var file in _uploadableFiles) {
|
||||
for (final (fileIndex, file) in _uploadableFiles.indexed) {
|
||||
var fileName = file.fileName;
|
||||
var filePath = file.filePath;
|
||||
final filePath = file.filePath;
|
||||
|
||||
if (widget.uniqueNames) {
|
||||
final unique = DateTime.now().microsecondsSinceEpoch.toRadixString(36);
|
||||
@@ -177,7 +191,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
|
||||
setState(() {
|
||||
_infoText =
|
||||
'${_uploadableFiles.indexOf(file) + 1}/${_uploadableFiles.length}';
|
||||
'${fileIndex + 1}/${_uploadableFiles.length}';
|
||||
});
|
||||
|
||||
// A vanished source file would otherwise surface as a cryptic
|
||||
@@ -193,7 +207,6 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
}
|
||||
|
||||
final HttpClientResponse uploadTask;
|
||||
final fileIndex = _uploadableFiles.indexOf(file);
|
||||
var lastPercent = -1;
|
||||
try {
|
||||
uploadTask = await webdavClient.putFile(
|
||||
@@ -253,12 +266,6 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
itemCount: _uploadableFiles.length,
|
||||
itemBuilder: (context, index) {
|
||||
final currentFile = _uploadableFiles[index];
|
||||
// Only sync when it differs: assigning text resets selection
|
||||
// and notifies the field on every (progress) rebuild.
|
||||
if (currentFile.fileNameController.text !=
|
||||
currentFile.fileName) {
|
||||
currentFile.fileNameController.text = currentFile.fileName;
|
||||
}
|
||||
return ListTile(
|
||||
title: TextField(
|
||||
readOnly: _isUploading,
|
||||
@@ -273,24 +280,11 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
onChanged: (input) {
|
||||
currentFile.fileName = input;
|
||||
},
|
||||
onTapOutside: (PointerDownEvent event) {
|
||||
FocusBehaviour.textFieldTapOutside(context);
|
||||
if (currentFile.isConflicting) {
|
||||
setState(() {
|
||||
currentFile.isConflicting = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
onEditingComplete: () {
|
||||
if (currentFile.isConflicting) {
|
||||
setState(() {
|
||||
currentFile.isConflicting = false;
|
||||
});
|
||||
}
|
||||
_clearConflict(currentFile);
|
||||
},
|
||||
onEditingComplete: () => _clearConflict(currentFile),
|
||||
),
|
||||
subtitle:
|
||||
_isUploading && (currentFile._uploadProgress ?? 0) < 1
|
||||
@@ -301,21 +295,23 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
),
|
||||
)
|
||||
: null,
|
||||
trailing: Container(
|
||||
trailing: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
padding: EdgeInsets.zero,
|
||||
child: IconButton(
|
||||
tooltip: 'Datei entfernen',
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: () {
|
||||
if (!_isUploading) {
|
||||
if (_uploadableFiles.length - 1 <= 0) {
|
||||
if (_uploadableFiles.length == 1) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
setState(() {
|
||||
_uploadableFiles.removeAt(index);
|
||||
});
|
||||
final removed = _uploadableFiles[index];
|
||||
setState(() => _uploadableFiles.removeAt(index));
|
||||
// Its TextField is still mounted until the rebuild.
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => removed.fileNameController.dispose(),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.delete_outlined),
|
||||
@@ -342,7 +338,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
),
|
||||
const Expanded(child: SizedBox.shrink()),
|
||||
const Spacer(),
|
||||
Visibility(
|
||||
visible: _isUploading,
|
||||
replacement: TextButton(
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 '../data/webdav_path.dart';
|
||||
import 'local_cache_search.dart';
|
||||
|
||||
/// Holds the live state of a Files-search session: current query, the latest
|
||||
@@ -28,12 +29,11 @@ class FilesSearchController extends ChangeNotifier {
|
||||
bool _disposed = false;
|
||||
Future<List<CacheableFile>>? _localIndex;
|
||||
|
||||
Future<List<CacheableFile>> _searchLocal(List<String> pathScope) async =>
|
||||
searchLocalCacheIndex(
|
||||
await (_localIndex ??= loadLocalCacheIndex()),
|
||||
_query,
|
||||
pathScope: pathScope,
|
||||
);
|
||||
Future<List<CacheableFile>> _searchLocal() async => searchLocalCacheIndex(
|
||||
await (_localIndex ??= loadLocalCacheIndex()),
|
||||
_query,
|
||||
pathScope: _pathScope,
|
||||
);
|
||||
|
||||
/// Guards against the race where the search delegate is closed (and the
|
||||
/// controller disposed) while a debounced cache scan or server call is
|
||||
@@ -45,10 +45,7 @@ class FilesSearchController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -81,18 +78,7 @@ class FilesSearchController extends ChangeNotifier {
|
||||
_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 _searchLocal(_pathScope);
|
||||
if (epoch != _serverEpoch) return;
|
||||
_cacheResults = cacheHits;
|
||||
_safeNotify();
|
||||
_scheduleServerCall();
|
||||
await _searchLocalThenServer(epoch);
|
||||
}
|
||||
|
||||
/// Drops the path filter and re-runs the current search globally. Used by
|
||||
@@ -105,11 +91,18 @@ class FilesSearchController extends ChangeNotifier {
|
||||
_safeNotify();
|
||||
return;
|
||||
}
|
||||
await _searchLocalThenServer(epoch);
|
||||
}
|
||||
|
||||
Future<void> _searchLocalThenServer(int epoch) async {
|
||||
// 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 _searchLocal(const []);
|
||||
final cacheHits = await _searchLocal();
|
||||
if (epoch != _serverEpoch) return;
|
||||
_cacheResults = cacheHits;
|
||||
_safeNotify();
|
||||
@@ -135,7 +128,7 @@ class FilesSearchController extends ChangeNotifier {
|
||||
Future<void> _runServerCall() async {
|
||||
final epoch = _serverEpoch;
|
||||
final term = _query;
|
||||
final scopePrefix = _pathScope.isEmpty ? '' : '${_pathScope.join('/')}/';
|
||||
final scopePrefix = webdavFolderPath(_pathScope);
|
||||
try {
|
||||
final response = await _api.run(term: term);
|
||||
if (epoch != _serverEpoch) return;
|
||||
|
||||
@@ -38,13 +38,7 @@ class FilesSearchDelegate extends SearchDelegate<void> {
|
||||
);
|
||||
|
||||
@override
|
||||
Widget buildResults(BuildContext context) {
|
||||
_controller.setQuery(query);
|
||||
return FilesSearchResults(
|
||||
controller: _controller,
|
||||
onResultTap: () => close(context, null),
|
||||
);
|
||||
}
|
||||
Widget buildResults(BuildContext context) => buildSuggestions(context);
|
||||
|
||||
@override
|
||||
Widget buildSuggestions(BuildContext context) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_
|
||||
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 '../data/webdav_path.dart';
|
||||
import '../widgets/file_element.dart';
|
||||
import 'files_search_controller.dart';
|
||||
|
||||
@@ -80,8 +81,8 @@ class FilesSearchResults extends StatelessWidget {
|
||||
visible: showErrorScreen,
|
||||
message: errorMessage,
|
||||
),
|
||||
if (showEmpty) _emptyState(context),
|
||||
if (hasContent) _resultList(context, combined),
|
||||
if (showEmpty) _emptyState(),
|
||||
if (hasContent) _resultList(combined),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -89,7 +90,7 @@ class FilesSearchResults extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _emptyState(BuildContext context) => PlaceholderView(
|
||||
Widget _emptyState() => PlaceholderView(
|
||||
icon: Icons.search_off_outlined,
|
||||
text: 'Keine Treffer gefunden.',
|
||||
button: controller.isScoped
|
||||
@@ -101,7 +102,7 @@ class FilesSearchResults extends StatelessWidget {
|
||||
: null,
|
||||
);
|
||||
|
||||
Widget _resultList(BuildContext context, List<CacheableFile> combined) {
|
||||
Widget _resultList(List<CacheableFile> combined) {
|
||||
final groups = _groupByParent(combined);
|
||||
final orderedKeys = groups.keys.toList()..sort();
|
||||
// Flat (folder header | file) rows built lazily: results can run into
|
||||
@@ -117,7 +118,7 @@ class FilesSearchResults extends StatelessWidget {
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, index) {
|
||||
final (folder, file) = rows[index];
|
||||
final segments = _segmentsOf(folder);
|
||||
final segments = webdavSegmentsOf(folder);
|
||||
if (file == null) {
|
||||
return _FolderHeader(
|
||||
key: ValueKey('folder:$folder'),
|
||||
@@ -139,29 +140,14 @@ class FilesSearchResults extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
static final RegExp _edgeSlashes = RegExp(r'^/+|/+$');
|
||||
|
||||
Map<String, List<CacheableFile>> _groupByParent(List<CacheableFile> files) {
|
||||
final map = <String, List<CacheableFile>>{};
|
||||
for (final file in files) {
|
||||
map.putIfAbsent(_parentOf(file), () => []).add(file);
|
||||
final parent = '/${webdavParentSegmentsOf(file.path).join('/')}';
|
||||
map.putIfAbsent(parent, () => []).add(file);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
String _parentOf(CacheableFile file) {
|
||||
final stripped = file.path.replaceAll(_edgeSlashes, '');
|
||||
final segments = stripped.split('/');
|
||||
if (segments.length <= 1) return '/';
|
||||
segments.removeLast();
|
||||
return '/${segments.join('/')}';
|
||||
}
|
||||
|
||||
List<String> _segmentsOf(String folder) {
|
||||
final stripped = folder.replaceAll(_edgeSlashes, '');
|
||||
if (stripped.isEmpty) return const [];
|
||||
return stripped.split('/');
|
||||
}
|
||||
}
|
||||
|
||||
class _FolderHeader extends StatelessWidget {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
|
||||
import '../../../../api/cache_store.dart';
|
||||
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
|
||||
import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
|
||||
import '../data/webdav_path.dart';
|
||||
|
||||
/// Document key prefix used by `ListFilesCache._documentId`.
|
||||
const String _folderCachePrefix = 'wd-folder-';
|
||||
@@ -42,18 +43,16 @@ List<CacheableFile> buildLocalCacheIndex(List<String> payloads) {
|
||||
/// Files in [index] 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.
|
||||
/// the given folder. Pass an empty list to search globally.
|
||||
List<CacheableFile> searchLocalCacheIndex(
|
||||
List<CacheableFile> index,
|
||||
String query, {
|
||||
List<String>? pathScope,
|
||||
List<String> pathScope = const [],
|
||||
}) {
|
||||
final trimmed = query.trim();
|
||||
if (trimmed.isEmpty) return const [];
|
||||
final needle = trimmed.toLowerCase();
|
||||
final scopePrefix = pathScope == null || pathScope.isEmpty
|
||||
? ''
|
||||
: '${pathScope.join('/')}/';
|
||||
final scopePrefix = webdavFolderPath(pathScope);
|
||||
return [
|
||||
for (final file in index)
|
||||
if (file.lowerName.contains(needle) &&
|
||||
|
||||
@@ -21,6 +21,18 @@ import 'share_password_sheet.dart';
|
||||
String _two(int n) => n.toString().padLeft(2, '0');
|
||||
String _isoDate(DateTime d) => '${d.year}-${_two(d.month)}-${_two(d.day)}';
|
||||
|
||||
/// Token of the Talk room a share points to; null for other share kinds.
|
||||
String? talkRoomTokenOf(Share share) {
|
||||
final token = share.shareWith;
|
||||
return share.isRoom && token != null && token.isNotEmpty ? token : null;
|
||||
}
|
||||
|
||||
/// Opens the Talk room and closes the sheet it was opened from.
|
||||
void openShareInTalk(BuildContext context, String token) {
|
||||
AppRoutes.openChatByToken(context, token);
|
||||
if (Navigator.of(context).canPop()) Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
IconData shareIcon(Share share) {
|
||||
if (share.isPublicLink) return Icons.link;
|
||||
if (share.isGroup) return Icons.groups_outlined;
|
||||
@@ -85,6 +97,9 @@ class _ShareOptionsBodyState extends State<_ShareOptionsBody> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _update(ShareUpdateParams params) =>
|
||||
_mutate(() => FileSharingApi().update(_share.id, params));
|
||||
|
||||
List<SharePreset> _availablePresets() => [
|
||||
SharePreset.readOnly,
|
||||
SharePreset.edit,
|
||||
@@ -93,15 +108,12 @@ class _ShareOptionsBodyState extends State<_ShareOptionsBody> {
|
||||
|
||||
Future<void> _setPreset(SharePreset preset) async {
|
||||
final caps = context.read<NextcloudCapabilitiesCubit>();
|
||||
await _mutate(
|
||||
() => FileSharingApi().update(
|
||||
_share.id,
|
||||
ShareUpdateParams(
|
||||
permissions: permissionsFor(
|
||||
preset,
|
||||
allowReshare: caps.canReshare,
|
||||
isFolder: _share.isFolder,
|
||||
),
|
||||
await _update(
|
||||
ShareUpdateParams(
|
||||
permissions: permissionsFor(
|
||||
preset,
|
||||
allowReshare: caps.canReshare,
|
||||
isFolder: _share.isFolder,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -121,20 +133,11 @@ class _ShareOptionsBodyState extends State<_ShareOptionsBody> {
|
||||
lastDate: lastDate,
|
||||
);
|
||||
if (picked == null) return;
|
||||
await _mutate(
|
||||
() => FileSharingApi().update(
|
||||
_share.id,
|
||||
ShareUpdateParams(expireDate: _isoDate(picked)),
|
||||
),
|
||||
);
|
||||
await _update(ShareUpdateParams(expireDate: _isoDate(picked)));
|
||||
}
|
||||
|
||||
Future<void> _clearExpiry() => _mutate(
|
||||
() => FileSharingApi().update(
|
||||
_share.id,
|
||||
const ShareUpdateParams(expireDate: ''),
|
||||
),
|
||||
);
|
||||
Future<void> _clearExpiry() =>
|
||||
_update(const ShareUpdateParams(expireDate: ''));
|
||||
|
||||
Future<void> _changePassword() async {
|
||||
final caps = context.read<NextcloudCapabilitiesCubit>();
|
||||
@@ -144,27 +147,11 @@ class _ShareOptionsBodyState extends State<_ShareOptionsBody> {
|
||||
policyHint: caps.passwordPolicyHint,
|
||||
);
|
||||
if (value == null || value.isEmpty || !mounted) return;
|
||||
await _mutate(
|
||||
() => FileSharingApi().update(
|
||||
_share.id,
|
||||
ShareUpdateParams(password: value),
|
||||
),
|
||||
);
|
||||
await _update(ShareUpdateParams(password: value));
|
||||
}
|
||||
|
||||
Future<void> _clearPassword() => _mutate(
|
||||
() => FileSharingApi().update(
|
||||
_share.id,
|
||||
const ShareUpdateParams(password: ''),
|
||||
),
|
||||
);
|
||||
|
||||
void _openInTalk() {
|
||||
final token = _share.shareWith;
|
||||
if (token == null || token.isEmpty) return;
|
||||
AppRoutes.openChatByToken(context, token);
|
||||
if (Navigator.of(context).canPop()) Navigator.of(context).pop();
|
||||
}
|
||||
Future<void> _clearPassword() =>
|
||||
_update(const ShareUpdateParams(password: ''));
|
||||
|
||||
Future<void> _confirmDelete() async {
|
||||
// Use showDialog directly (not asDialog) so we get the AsyncDialogAction's
|
||||
@@ -217,12 +204,12 @@ class _ShareOptionsBodyState extends State<_ShareOptionsBody> {
|
||||
),
|
||||
),
|
||||
|
||||
if (_share.isRoom && (_share.shareWith?.isNotEmpty ?? false)) ...[
|
||||
if (talkRoomTokenOf(_share) case final token?) ...[
|
||||
const Divider(height: 24),
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.chat_bubble_outline)),
|
||||
title: const Text('Im Talk-Chat öffnen'),
|
||||
onTap: _busy ? null : _openInTalk,
|
||||
onTap: _busy ? null : () => openShareInTalk(context, token),
|
||||
),
|
||||
],
|
||||
|
||||
|
||||
@@ -47,19 +47,13 @@ class _ShareSheetBody extends StatefulWidget {
|
||||
|
||||
class _ShareSheetBodyState extends State<_ShareSheetBody> {
|
||||
late final String _ocsPath = ocsPathOf(widget.file);
|
||||
Future<List<Share>>? _future;
|
||||
late Future<List<Share>> _future = FileSharingApi().listForPath(_ocsPath);
|
||||
bool _busy = false;
|
||||
|
||||
/// Last resolved share list — used by the create-link gate without depending
|
||||
/// on the FutureBuilder's snapshot.
|
||||
List<Share>? _lastShares;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = FileSharingApi().listForPath(_ocsPath);
|
||||
}
|
||||
|
||||
void _reload() {
|
||||
// Block body: an arrow would return the Future, which setState rejects.
|
||||
setState(() {
|
||||
@@ -144,26 +138,19 @@ class _ShareSheetBodyState extends State<_ShareSheetBody> {
|
||||
leading: const CenteredLeading(Icon(Icons.person_add_outlined)),
|
||||
title: const Text('Person oder Gruppe hinzufügen'),
|
||||
enabled: !_busy,
|
||||
onTap: _busy ? null : _addSharee,
|
||||
onTap: _addSharee,
|
||||
),
|
||||
if (caps.canCreatePublicLinks && _canAddLink(caps))
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.add_link)),
|
||||
title: const Text('Öffentlichen Link erstellen'),
|
||||
enabled: !_busy,
|
||||
onTap: _busy ? null : _createPublicLink,
|
||||
onTap: _createPublicLink,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _openInTalk(Share share) {
|
||||
final token = share.shareWith;
|
||||
if (token == null || token.isEmpty) return;
|
||||
AppRoutes.openChatByToken(context, token);
|
||||
if (Navigator.of(context).canPop()) Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
/// Whether another public link may be created — hidden once one exists on a
|
||||
/// server that disallows multiple links.
|
||||
bool _canAddLink(NextcloudCapabilitiesCubit caps) {
|
||||
@@ -271,11 +258,11 @@ class _ShareSheetBodyState extends State<_ShareSheetBody> {
|
||||
tooltip: 'Link kopieren',
|
||||
onPressed: () => copyToClipboard(context, share.url!),
|
||||
),
|
||||
if (share.isRoom && (share.shareWith?.isNotEmpty ?? false))
|
||||
if (talkRoomTokenOf(share) case final token?)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chat_bubble_outline),
|
||||
tooltip: 'Im Talk-Chat öffnen',
|
||||
onPressed: () => _openInTalk(share),
|
||||
onPressed: () => openShareInTalk(context, token),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_cache
|
||||
import '../../../../api/marianumcloud/webdav/webdav_api.dart';
|
||||
import '../../../../utils/file_clipboard.dart';
|
||||
import '../../../../widget/info_dialog.dart';
|
||||
import '../data/webdav_path.dart';
|
||||
|
||||
/// Banner that appears at the top of a Files folder while there is something
|
||||
/// in the file clipboard. Shows the cut/copy state and offers a "Hier
|
||||
@@ -26,16 +27,6 @@ class ClipboardBanner extends StatefulWidget {
|
||||
class _ClipboardBannerState extends State<ClipboardBanner> {
|
||||
bool _busy = false;
|
||||
|
||||
// All paths here are relative to the WebDAV root (matching `CacheableFile.path`).
|
||||
// Root is the empty string ''. Folders end with '/'.
|
||||
String _normalised(String path) {
|
||||
final stripped = path.replaceAll(RegExp(r'^/+|/+$'), '');
|
||||
return stripped.isEmpty ? '' : '$stripped/';
|
||||
}
|
||||
|
||||
String _joinPath(String folder, String name, {required bool isDirectory}) =>
|
||||
isDirectory ? '$folder$name/' : '$folder$name';
|
||||
|
||||
// Disabled when:
|
||||
// - clipboard is empty
|
||||
// - we'd be pasting a folder into itself or one of its descendants
|
||||
@@ -43,14 +34,14 @@ class _ClipboardBannerState extends State<ClipboardBanner> {
|
||||
bool get _canPaste {
|
||||
final cb = FileClipboard.instance;
|
||||
if (cb.isEmpty) return false;
|
||||
final dst = _normalised(widget.currentFolder);
|
||||
final dst = webdavFolderPath(webdavSegmentsOf(widget.currentFolder));
|
||||
var atLeastOneActionable = false;
|
||||
for (final f in cb.files) {
|
||||
if (f.isDirectory) {
|
||||
final src = _normalised(f.path);
|
||||
final src = webdavFolderPath(webdavSegmentsOf(f.path));
|
||||
if (dst == src || dst.startsWith(src)) return false;
|
||||
}
|
||||
final destination = _joinPath(
|
||||
final destination = webdavJoin(
|
||||
widget.currentFolder,
|
||||
f.name,
|
||||
isDirectory: f.isDirectory,
|
||||
@@ -60,15 +51,6 @@ class _ClipboardBannerState extends State<ClipboardBanner> {
|
||||
return atLeastOneActionable;
|
||||
}
|
||||
|
||||
// Cache key format used by ListFilesCache (matches FilesBloc's pathString:
|
||||
// relative, no leading or trailing slash; root is '/').
|
||||
String _parentCacheKey(String relativePath) {
|
||||
final stripped = relativePath.replaceAll(RegExp(r'^/+|/+$'), '');
|
||||
if (!stripped.contains('/')) return '/';
|
||||
final parts = stripped.split('/')..removeLast();
|
||||
return parts.isEmpty ? '/' : parts.join('/');
|
||||
}
|
||||
|
||||
Future<void> _paste() async {
|
||||
final cb = FileClipboard.instance;
|
||||
if (_busy || !_canPaste) return;
|
||||
@@ -79,7 +61,7 @@ class _ClipboardBannerState extends State<ClipboardBanner> {
|
||||
try {
|
||||
final webdav = await WebdavApi.webdav;
|
||||
for (final file in cb.files) {
|
||||
final destination = _joinPath(
|
||||
final destination = webdavJoin(
|
||||
widget.currentFolder,
|
||||
file.name,
|
||||
isDirectory: file.isDirectory,
|
||||
@@ -91,7 +73,9 @@ class _ClipboardBannerState extends State<ClipboardBanner> {
|
||||
PathUri.parse(file.path),
|
||||
PathUri.parse(destination),
|
||||
);
|
||||
invalidatedSourceFolders.add(_parentCacheKey(file.path));
|
||||
invalidatedSourceFolders.add(
|
||||
listFilesCacheKey(webdavParentSegmentsOf(file.path)),
|
||||
);
|
||||
} else {
|
||||
await webdav.copy(
|
||||
PathUri.parse(file.path),
|
||||
@@ -105,9 +89,9 @@ class _ClipboardBannerState extends State<ClipboardBanner> {
|
||||
// After cut, the source folders no longer contain the moved files. Drop
|
||||
// their cached listings so the next visit fetches fresh data instead of
|
||||
// briefly showing the moved file as still present.
|
||||
for (final folder in invalidatedSourceFolders) {
|
||||
await ListFilesCache.invalidate(folder);
|
||||
}
|
||||
await Future.wait(
|
||||
invalidatedSourceFolders.map(ListFilesCache.invalidate),
|
||||
);
|
||||
if (operation == FileClipboardOperation.cut) cb.clear();
|
||||
widget.onPasteDone();
|
||||
} finally {
|
||||
|
||||
@@ -19,6 +19,7 @@ import '../../../../widget/details_bottom_sheet.dart';
|
||||
import '../../../../widget/downloads/download_trigger.dart';
|
||||
import '../../../../widget/prompt_dialog.dart';
|
||||
import '../../talk/widgets/highlighted_linkify.dart';
|
||||
import '../data/webdav_path.dart';
|
||||
import '../sharing/share_sheet.dart';
|
||||
import 'file_details_sheet.dart';
|
||||
import 'file_leading.dart';
|
||||
@@ -26,7 +27,7 @@ import 'file_leading.dart';
|
||||
class FileElement extends StatefulWidget {
|
||||
final CacheableFile file;
|
||||
final List<String> path;
|
||||
final void Function() refetch;
|
||||
final VoidCallback refetch;
|
||||
|
||||
/// When non-null, occurrences of this string in the file name are visually
|
||||
/// highlighted in the tile title. Used by the Files search delegate.
|
||||
@@ -101,7 +102,7 @@ class _FileElementState extends State<FileElement>
|
||||
if (widget.file.isDirectory) {
|
||||
AppRoutes.openFolder(
|
||||
context,
|
||||
widget.path.toList()..add(widget.file.name),
|
||||
[...widget.path, widget.file.name],
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -116,18 +117,6 @@ class _FileElementState extends State<FileElement>
|
||||
);
|
||||
}
|
||||
|
||||
// All paths here are relative to the WebDAV root (matching CacheableFile.path).
|
||||
// Root parent is the empty string ''. Folders end with '/'.
|
||||
String _parentPathOf(String path) {
|
||||
final stripped = path.replaceAll(RegExp(r'^/+|/+$'), '');
|
||||
if (!stripped.contains('/')) return '';
|
||||
final parts = stripped.split('/')..removeLast();
|
||||
return parts.isEmpty ? '' : '${parts.join('/')}/';
|
||||
}
|
||||
|
||||
String _joinPath(String folder, String name, {required bool isDirectory}) =>
|
||||
isDirectory ? '$folder$name/' : '$folder$name';
|
||||
|
||||
void _rename() {
|
||||
if (guardDemoAction(context)) return;
|
||||
showPromptDialog(
|
||||
@@ -138,9 +127,8 @@ class _FileElementState extends State<FileElement>
|
||||
initialValue: widget.file.name,
|
||||
onConfirm: (newName) async {
|
||||
if (newName.isEmpty || newName == widget.file.name) return;
|
||||
final parent = _parentPathOf(widget.file.path);
|
||||
final destination = _joinPath(
|
||||
parent,
|
||||
final destination = webdavJoin(
|
||||
webdavFolderPath(webdavParentSegmentsOf(widget.file.path)),
|
||||
newName,
|
||||
isDirectory: widget.file.isDirectory,
|
||||
);
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../data/sort_options.dart';
|
||||
|
||||
/// Sort direction and sort key pickers, read from and persisted to the file
|
||||
/// settings.
|
||||
class FilesSortActions extends StatelessWidget {
|
||||
final SortOption currentSort;
|
||||
final bool ascending;
|
||||
final ValueChanged<bool> onDirectionChanged;
|
||||
final ValueChanged<SortOption> onSortChanged;
|
||||
|
||||
const FilesSortActions({
|
||||
required this.currentSort,
|
||||
required this.ascending,
|
||||
required this.onDirectionChanged,
|
||||
required this.onSortChanged,
|
||||
super.key,
|
||||
});
|
||||
const FilesSortActions({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final settings = context.read<SettingsCubit>();
|
||||
final (currentSort, ascending) = context.select(
|
||||
(SettingsCubit c) =>
|
||||
(c.state.fileSettings.sortBy, c.state.fileSettings.ascending),
|
||||
);
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -46,7 +44,7 @@ class FilesSortActions extends StatelessWidget {
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onSelected: onDirectionChanged,
|
||||
onSelected: (e) => settings.val(write: true).fileSettings.ascending = e,
|
||||
),
|
||||
PopupMenuButton<SortOption>(
|
||||
tooltip:
|
||||
@@ -70,7 +68,7 @@ class FilesSortActions extends StatelessWidget {
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onSelected: onSortChanged,
|
||||
onSelected: (e) => settings.val(write: true).fileSettings.sortBy = e,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../storage/timetable_favorites_settings.dart';
|
||||
import '../../../utils/haptics.dart';
|
||||
import '../../../widget/app_progress_indicator.dart';
|
||||
import 'element_type_icon.dart';
|
||||
|
||||
/// Full-screen picker: choose an element type (or "Alle" to search across all
|
||||
/// types), filter the (potentially large) list client-side, and pick an element
|
||||
@@ -221,7 +222,7 @@ class _ElementPickerPageState extends State<ElementPickerPage> {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
avatar: Icon(type == null ? Icons.apps : _iconFor(type), size: 18),
|
||||
avatar: Icon(type == null ? Icons.apps : type.icon, size: 18),
|
||||
showCheckmark: false,
|
||||
label: Text(type?.label ?? 'Alle'),
|
||||
selected: _selectedType == type,
|
||||
@@ -293,7 +294,7 @@ class _ElementPickerPageState extends State<ElementPickerPage> {
|
||||
}
|
||||
}
|
||||
final item = filtered[index - headerCount];
|
||||
return _itemTile(item);
|
||||
return _itemTile(item, favorites);
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -316,7 +317,7 @@ class _ElementPickerPageState extends State<ElementPickerPage> {
|
||||
|
||||
Widget _favoriteTile(FavoriteTimetableElement favorite) {
|
||||
return ListTile(
|
||||
leading: Icon(_iconFor(favorite.type)),
|
||||
leading: Icon(favorite.type.icon),
|
||||
title: Text(favorite.label),
|
||||
subtitle: Text(favorite.type.label),
|
||||
trailing: IconButton(
|
||||
@@ -329,12 +330,10 @@ class _ElementPickerPageState extends State<ElementPickerPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _itemTile(_PickerItem item) {
|
||||
final isFavorite = context
|
||||
.watch<SettingsCubit>()
|
||||
.val()
|
||||
.timetableFavoritesSettings
|
||||
.isFavorite(item.type, item.id);
|
||||
Widget _itemTile(_PickerItem item, List<FavoriteTimetableElement> favorites) {
|
||||
final isFavorite = favorites.any(
|
||||
(f) => f.type == item.type && f.id == item.id,
|
||||
);
|
||||
|
||||
// In the "Alle" tab the type is otherwise ambiguous, so surface it.
|
||||
final subtitleParts = <String>[
|
||||
@@ -344,7 +343,7 @@ class _ElementPickerPageState extends State<ElementPickerPage> {
|
||||
];
|
||||
|
||||
return ListTile(
|
||||
leading: Icon(_iconFor(item.type)),
|
||||
leading: Icon(item.type.icon),
|
||||
title: Text(item.primary),
|
||||
subtitle: subtitleParts.isEmpty ? null : Text(subtitleParts.join(' · ')),
|
||||
trailing: IconButton(
|
||||
@@ -355,19 +354,6 @@ class _ElementPickerPageState extends State<ElementPickerPage> {
|
||||
onTap: () => _open(item),
|
||||
);
|
||||
}
|
||||
|
||||
static IconData _iconFor(TimetableElementType type) {
|
||||
switch (type) {
|
||||
case TimetableElementType.student:
|
||||
return Icons.person_outline;
|
||||
case TimetableElementType.teacher:
|
||||
return Icons.school_outlined;
|
||||
case TimetableElementType.room:
|
||||
return Icons.meeting_room_outlined;
|
||||
case TimetableElementType.schoolClass:
|
||||
return Icons.groups_outlined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _PickerItem {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
|
||||
|
||||
extension TimetableElementTypeIcon on TimetableElementType {
|
||||
IconData get icon => switch (this) {
|
||||
TimetableElementType.student => Icons.person_outline,
|
||||
TimetableElementType.teacher => Icons.school_outlined,
|
||||
TimetableElementType.room => Icons.meeting_room_outlined,
|
||||
TimetableElementType.schoolClass => Icons.groups_outlined,
|
||||
};
|
||||
}
|
||||
@@ -15,14 +15,14 @@ class GradeAveragesView extends StatelessWidget {
|
||||
create: (context) => GradeAveragesBloc(),
|
||||
child: BlocBuilder<GradeAveragesBloc, GradeAveragesState>(
|
||||
builder: (context, state) {
|
||||
var bloc = context.watch<GradeAveragesBloc>();
|
||||
final bloc = context.read<GradeAveragesBloc>();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Notendurchschnittsrechner'),
|
||||
actions: [
|
||||
Visibility(
|
||||
visible: bloc.state.grades.isNotEmpty,
|
||||
visible: state.grades.isNotEmpty,
|
||||
child: IconButton(
|
||||
tooltip: 'Alle zurücksetzen',
|
||||
onPressed: () => ConfirmDialog(
|
||||
@@ -59,7 +59,7 @@ class GradeAveragesView extends StatelessWidget {
|
||||
)
|
||||
.toList(),
|
||||
onSelected: (isMiddleSchool) {
|
||||
if (bloc.state.grades.isNotEmpty) {
|
||||
if (state.grades.isNotEmpty) {
|
||||
ConfirmDialog(
|
||||
title: 'Notensystem wechseln',
|
||||
content:
|
||||
|
||||
@@ -78,7 +78,7 @@ class HolidaysView extends StatelessWidget {
|
||||
},
|
||||
child: (state, loading) {
|
||||
final holidays = bloc.getHolidays();
|
||||
if (holidays == null || holidays.isEmpty) {
|
||||
if (holidays.isEmpty) {
|
||||
return const PlaceholderView(
|
||||
icon: Icons.beach_access_outlined,
|
||||
text: 'Keine Schulferien verfügbar',
|
||||
|
||||
@@ -71,20 +71,16 @@ class MarianumDatesView extends StatelessWidget {
|
||||
IconButton(
|
||||
tooltip: 'Suchen',
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () {
|
||||
final events = bloc.getEvents() ?? const <MarianumDate>[];
|
||||
showSearch(
|
||||
context: context,
|
||||
delegate: SearchMarianumDates(events),
|
||||
);
|
||||
},
|
||||
onPressed: () => showSearch(
|
||||
context: context,
|
||||
delegate: SearchMarianumDates(bloc.getEvents()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: LoadableStateConsumer<MarianumDatesBloc, MarianumDatesState>(
|
||||
child: (state, loading) {
|
||||
final events = bloc.getEvents() ?? const <MarianumDate>[];
|
||||
final groups = _groupByMonth(events);
|
||||
final groups = _groupByMonth(bloc.getEvents());
|
||||
|
||||
if (groups.isEmpty) {
|
||||
return const PlaceholderView(
|
||||
|
||||
@@ -17,6 +17,8 @@ class Overhang extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _OverhangState extends State<Overhang> {
|
||||
late final Future<bool> _reviewAvailable = InAppReview.instance.isAvailable();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -48,7 +50,7 @@ class _OverhangState extends State<Overhang> {
|
||||
onTap: () => AppRoutes.openQrShare(context),
|
||||
),
|
||||
FutureBuilder(
|
||||
future: InAppReview.instance.isAvailable(),
|
||||
future: _reviewAvailable,
|
||||
builder: (context, snapshot) {
|
||||
String? getPlatformStoreName() {
|
||||
if (Platform.isAndroid) return 'Play store';
|
||||
@@ -60,19 +62,19 @@ class _OverhangState extends State<Overhang> {
|
||||
.openStoreListing(appStoreId: '6458789560')
|
||||
.then(
|
||||
(value) {
|
||||
if (!context.mounted) return;
|
||||
InfoDialog.show(context, 'Vielen Dank!');
|
||||
},
|
||||
onError: (error) {
|
||||
if (!context.mounted) return;
|
||||
InfoDialog.show(
|
||||
context,
|
||||
error.toString(),
|
||||
copyable: true,
|
||||
title: 'Fehler',
|
||||
if (!context.mounted) return;
|
||||
InfoDialog.show(context, 'Vielen Dank!');
|
||||
},
|
||||
onError: (error) {
|
||||
if (!context.mounted) return;
|
||||
InfoDialog.show(
|
||||
context,
|
||||
error.toString(),
|
||||
copyable: true,
|
||||
title: 'Fehler',
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final ready = snapshot.connectionState == ConnectionState.done;
|
||||
return ListTile(
|
||||
@@ -83,7 +85,7 @@ class _OverhangState extends State<Overhang> {
|
||||
(data) => Text('Im $data'),
|
||||
),
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: !ready ? null : rate
|
||||
onTap: !ready ? null : rate,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../../../storage/chat_background_settings.dart';
|
||||
import '../../../storage/settings.dart' as model;
|
||||
import '../../../utils/app_paths.dart';
|
||||
import '../../../utils/haptics.dart';
|
||||
import '../../../utils/json_equality.dart';
|
||||
import '../../../widget/async_action_button.dart';
|
||||
import '../../../widget/chat_background.dart';
|
||||
import '../../../widget/chat_background_picker_sheet.dart';
|
||||
@@ -43,9 +44,10 @@ class ChatBackgroundSettingsPage extends StatelessWidget {
|
||||
builder: (context, _) {
|
||||
final settings = context.read<SettingsCubit>();
|
||||
final s = settings.val().chatBackgroundSettings;
|
||||
final isModified =
|
||||
s.toJson().toString() !=
|
||||
DefaultSettings.get().chatBackgroundSettings.toJson().toString();
|
||||
final isModified = !sameJson(
|
||||
s,
|
||||
DefaultSettings.get().chatBackgroundSettings,
|
||||
);
|
||||
final showImageControls =
|
||||
s.type == ChatBackgroundType.pattern ||
|
||||
s.type == ChatBackgroundType.image;
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../../state/app/modules/app_modules.dart';
|
||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../storage/settings.dart' as model;
|
||||
import '../../../utils/haptics.dart';
|
||||
import '../../../utils/json_equality.dart';
|
||||
import '../../../widget/confirm_dialog.dart';
|
||||
import 'data/default_settings.dart';
|
||||
|
||||
@@ -147,9 +148,10 @@ class ModulesSettingsPage extends StatelessWidget {
|
||||
BlocBuilder<SettingsCubit, model.Settings>(
|
||||
builder: (context, _) {
|
||||
final settings = context.read<SettingsCubit>();
|
||||
final isModified =
|
||||
settings.val().modulesSettings.toJson().toString() !=
|
||||
DefaultSettings.get().modulesSettings.toJson().toString();
|
||||
final isModified = !sameJson(
|
||||
settings.val().modulesSettings,
|
||||
DefaultSettings.get().modulesSettings,
|
||||
);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Module'),
|
||||
|
||||
@@ -50,10 +50,7 @@ class AboutSection extends StatelessWidget {
|
||||
_toggleDeveloperMode(context, settings, state),
|
||||
),
|
||||
),
|
||||
Visibility(
|
||||
visible: settings.val().devToolsEnabled,
|
||||
child: DevToolsSection(settings: settings),
|
||||
),
|
||||
if (settings.val().devToolsEnabled) DevToolsSection(settings: settings),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -104,7 +101,9 @@ class AboutSection extends StatelessWidget {
|
||||
Icon(Icons.send_time_extension_outlined),
|
||||
),
|
||||
title: const Text('Infos zu mhsl'),
|
||||
subtitle: const Text('Für Push, Kalendertermine, Marianum Message und mehr'),
|
||||
subtitle: const Text(
|
||||
'Für Push, Kalendertermine, Marianum Message und mehr',
|
||||
),
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: () => PrivacyInfo(
|
||||
providerText: 'mhsl',
|
||||
|
||||
@@ -22,21 +22,14 @@ import '../../../../widget/user_avatar.dart';
|
||||
String? _cachedDisplayName;
|
||||
String? _cachedDisplayNameFor;
|
||||
|
||||
class AccountSection extends StatelessWidget {
|
||||
class AccountSection extends StatefulWidget {
|
||||
const AccountSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const _SchoolAccount();
|
||||
State<AccountSection> createState() => _AccountSectionState();
|
||||
}
|
||||
|
||||
class _SchoolAccount extends StatefulWidget {
|
||||
const _SchoolAccount();
|
||||
|
||||
@override
|
||||
State<_SchoolAccount> createState() => _SchoolAccountState();
|
||||
}
|
||||
|
||||
class _SchoolAccountState extends State<_SchoolAccount> {
|
||||
class _AccountSectionState extends State<AccountSection> {
|
||||
int _avatarVersion = 0;
|
||||
bool _avatarBusy = false;
|
||||
String? _displayName;
|
||||
|
||||
@@ -41,25 +41,15 @@ class AppearanceSection extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
IconData _hapticIcon(HapticLevel level) {
|
||||
switch (level) {
|
||||
case HapticLevel.off:
|
||||
return Icons.notifications_off_outlined;
|
||||
case HapticLevel.reduced:
|
||||
return Icons.notifications_paused_outlined;
|
||||
case HapticLevel.full:
|
||||
return Icons.notifications_active_outlined;
|
||||
}
|
||||
}
|
||||
IconData _hapticIcon(HapticLevel level) => switch (level) {
|
||||
HapticLevel.off => Icons.notifications_off_outlined,
|
||||
HapticLevel.reduced => Icons.notifications_paused_outlined,
|
||||
HapticLevel.full => Icons.notifications_active_outlined,
|
||||
};
|
||||
|
||||
String _hapticLabel(HapticLevel level) {
|
||||
switch (level) {
|
||||
case HapticLevel.off:
|
||||
return 'Aus';
|
||||
case HapticLevel.reduced:
|
||||
return 'Reduziert';
|
||||
case HapticLevel.full:
|
||||
return 'Vollständig';
|
||||
}
|
||||
}
|
||||
String _hapticLabel(HapticLevel level) => switch (level) {
|
||||
HapticLevel.off => 'Aus',
|
||||
HapticLevel.reduced => 'Reduziert',
|
||||
HapticLevel.full => 'Vollständig',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,15 +26,10 @@ import '../widgets/endpoint_picker.dart';
|
||||
import '../widgets/settings_checkbox_tile.dart';
|
||||
import '../widgets/settings_dropdown_tile.dart';
|
||||
|
||||
class DevToolsSection extends StatefulWidget {
|
||||
class DevToolsSection extends StatelessWidget {
|
||||
final SettingsCubit settings;
|
||||
const DevToolsSection({required this.settings, super.key});
|
||||
|
||||
@override
|
||||
State<DevToolsSection> createState() => _DevToolsSectionState();
|
||||
}
|
||||
|
||||
class _DevToolsSectionState extends State<DevToolsSection> {
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
children: [
|
||||
@@ -54,11 +49,11 @@ class _DevToolsSectionState extends State<DevToolsSection> {
|
||||
context,
|
||||
children: (sheetCtx) => [
|
||||
BlocBuilder<SettingsCubit, model.Settings>(
|
||||
bloc: widget.settings,
|
||||
bloc: settings,
|
||||
builder: (_, _) {
|
||||
final dev = widget.settings.val().devToolsSettings;
|
||||
final dev = settings.val().devToolsSettings;
|
||||
DevToolsSettings write() =>
|
||||
widget.settings.val(write: true).devToolsSettings;
|
||||
settings.val(write: true).devToolsSettings;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -122,17 +117,17 @@ class _DevToolsSectionState extends State<DevToolsSection> {
|
||||
title: const Text('Marianum-Connect-Server'),
|
||||
subtitle: Text(
|
||||
MarianumConnectEndpointPicker.labelFor(
|
||||
widget.settings.val().devToolsSettings,
|
||||
settings.val().devToolsSettings,
|
||||
),
|
||||
),
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: () =>
|
||||
MarianumConnectEndpointPicker.show(context, widget.settings),
|
||||
MarianumConnectEndpointPicker.show(context, settings),
|
||||
),
|
||||
BlocBuilder<SettingsCubit, model.Settings>(
|
||||
bloc: widget.settings,
|
||||
bloc: settings,
|
||||
builder: (_, _) {
|
||||
final override = widget.settings
|
||||
final override = settings
|
||||
.val()
|
||||
.devToolsSettings
|
||||
.emergencyNoticeUrl
|
||||
@@ -143,7 +138,7 @@ class _DevToolsSectionState extends State<DevToolsSection> {
|
||||
subtitle: Text(override.isEmpty ? 'Standardquelle' : override),
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: () =>
|
||||
_EmergencyNoticeUrlEditor.show(context, widget.settings),
|
||||
_EmergencyNoticeUrlEditor.show(context, settings),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -152,18 +147,18 @@ class _DevToolsSectionState extends State<DevToolsSection> {
|
||||
icon: Icons.settings_applications_outlined,
|
||||
title: 'Einstellungen',
|
||||
describe: () async => filesize(
|
||||
utf8.encode(jsonEncode(widget.settings.val().toJson())).length,
|
||||
utf8.encode(jsonEncode(settings.val().toJson())).length,
|
||||
),
|
||||
onOpen: (_) async => AppRoutes.openJsonViewer(
|
||||
context,
|
||||
widget.settings.val().toJson(),
|
||||
settings.val().toJson(),
|
||||
title: 'Einstellungen',
|
||||
),
|
||||
clearTitle: 'Einstellungen zurücksetzen',
|
||||
clearContent:
|
||||
'Alle Einstellungen gehen verloren! Accountdaten sowie App-Daten '
|
||||
'sind nicht betroffen.',
|
||||
onClear: widget.settings.reset,
|
||||
onClear: settings.reset,
|
||||
),
|
||||
_StorageTile(
|
||||
icon: Icons.account_tree_outlined,
|
||||
|
||||
@@ -5,11 +5,11 @@ import '../../../../widget/confirm_dialog.dart';
|
||||
import '../../../../widget/details_bottom_sheet.dart';
|
||||
|
||||
class PrivacyInfo {
|
||||
String providerText;
|
||||
String privacyUrl;
|
||||
String imprintUrl;
|
||||
final String providerText;
|
||||
final String privacyUrl;
|
||||
final String imprintUrl;
|
||||
|
||||
PrivacyInfo({
|
||||
const PrivacyInfo({
|
||||
required this.providerText,
|
||||
required this.imprintUrl,
|
||||
required this.privacyUrl,
|
||||
|
||||
@@ -228,11 +228,7 @@ class _PushStatusBodyState extends State<_PushStatusBody>
|
||||
/// otherwise re-registering IS the primary next step and the test action is
|
||||
/// omitted (it could not succeed and the checklist explains why).
|
||||
Widget _actions(PushStatusReport report) {
|
||||
const spinner = SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
);
|
||||
const spinner = AppProgressIndicator.small();
|
||||
final ready = report.readyForTestNotification;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
@@ -67,7 +67,12 @@ class ShareChatPicker extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final talkSettings = context.watch<SettingsCubit>().val().talkSettings;
|
||||
final (favoritesToTop, unreadToTop) = context.select(
|
||||
(SettingsCubit c) => (
|
||||
c.state.talkSettings.sortFavoritesToTop,
|
||||
c.state.talkSettings.sortUnreadToTop,
|
||||
),
|
||||
);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Talk-Chat auswählen'),
|
||||
@@ -100,8 +105,8 @@ class ShareChatPicker extends StatelessWidget {
|
||||
if (rooms == null) return const SizedBox.shrink();
|
||||
final sorted = rooms
|
||||
.sortBy(
|
||||
favoritesToTop: talkSettings.sortFavoritesToTop,
|
||||
unreadToTop: talkSettings.sortUnreadToTop,
|
||||
favoritesToTop: favoritesToTop,
|
||||
unreadToTop: unreadToTop,
|
||||
)
|
||||
// Hide chats the user can't write to (announcement channels,
|
||||
// archived rooms, …) — uploading there would only fail at the
|
||||
@@ -195,8 +200,8 @@ Future<void> _runShareFlow(
|
||||
try {
|
||||
await action();
|
||||
} catch (e) {
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
InfoDialog.show(
|
||||
context,
|
||||
errorToUserMessage(e),
|
||||
|
||||
@@ -13,11 +13,10 @@ import '../../../state/app/infrastructure/loadable_state/view/loadable_state_con
|
||||
import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
|
||||
import '../../../state/app/modules/files/bloc/files_bloc.dart';
|
||||
import '../../../state/app/modules/files/bloc/files_state.dart';
|
||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../widget/centered_leading.dart';
|
||||
import '../../../widget/info_dialog.dart';
|
||||
import '../../../widget/placeholder_view.dart';
|
||||
import '../files/data/sort_options.dart';
|
||||
import '../files/data/file_sort.dart';
|
||||
import '../files/files_upload_dialog.dart';
|
||||
import '../files/widgets/add_file_menu.dart';
|
||||
import '../files/widgets/file_leading.dart';
|
||||
@@ -73,22 +72,6 @@ class _ShareFolderPickerView extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> {
|
||||
late final SettingsCubit _settings;
|
||||
late SortOption _currentSort;
|
||||
late bool _ascending;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_settings = context.read<SettingsCubit>();
|
||||
_currentSort = _settings.val().fileSettings.sortBy;
|
||||
_ascending = _settings.val().fileSettings.ascending;
|
||||
}
|
||||
|
||||
void _enter(FilesBloc bloc, List<String> currentPath, String folderName) {
|
||||
bloc.setPath([...currentPath, folderName]);
|
||||
}
|
||||
|
||||
void _goUp(FilesBloc bloc, List<String> currentPath) {
|
||||
if (currentPath.isEmpty) return;
|
||||
bloc.setPath(currentPath.sublist(0, currentPath.length - 1));
|
||||
@@ -97,6 +80,9 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bloc = context.read<FilesBloc>();
|
||||
// Selected here, not in the LoadableStateConsumer child: that closure runs
|
||||
// during the consumer's build, where this context may not subscribe.
|
||||
final sort = context.select(fileSortOf);
|
||||
return BlocBuilder<FilesBloc, LoadableState<FilesState>>(
|
||||
buildWhen: (a, b) => a.data?.currentPath != b.data?.currentPath,
|
||||
builder: (_, outerState) {
|
||||
@@ -109,9 +95,9 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> {
|
||||
canPop: currentPath.isEmpty,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) return;
|
||||
if (currentPath.isNotEmpty) _goUp(bloc, currentPath);
|
||||
_goUp(bloc, currentPath);
|
||||
},
|
||||
child: _buildScaffold(context, bloc, currentPath),
|
||||
child: _buildScaffold(context, bloc, currentPath, sort),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -121,6 +107,7 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> {
|
||||
BuildContext context,
|
||||
FilesBloc bloc,
|
||||
List<String> currentPath,
|
||||
FileSort sort,
|
||||
) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
@@ -133,22 +120,7 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> {
|
||||
tooltip: 'Ordner erstellen',
|
||||
onPressed: () => showCreateFolderDialog(context, bloc),
|
||||
),
|
||||
FilesSortActions(
|
||||
currentSort: _currentSort,
|
||||
ascending: _ascending,
|
||||
onDirectionChanged: (e) {
|
||||
setState(() {
|
||||
_ascending = e;
|
||||
_settings.val(write: true).fileSettings.ascending = e;
|
||||
});
|
||||
},
|
||||
onSortChanged: (e) {
|
||||
setState(() {
|
||||
_currentSort = e;
|
||||
_settings.val(write: true).fileSettings.sortBy = e;
|
||||
});
|
||||
},
|
||||
),
|
||||
const FilesSortActions(),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
@@ -160,12 +132,7 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> {
|
||||
body: LoadableStateConsumer<FilesBloc, FilesState>(
|
||||
isReady: (state) => state.listing != null,
|
||||
child: (state, _) {
|
||||
final listing = state.listing!;
|
||||
final entries = listing.sortBy(
|
||||
sortOption: _currentSort,
|
||||
foldersToTop: _settings.val().fileSettings.sortFoldersToTop,
|
||||
reversed: _ascending,
|
||||
);
|
||||
final entries = state.listing!.sortedBy(sort);
|
||||
|
||||
if (entries.isEmpty) {
|
||||
return PlaceholderView(
|
||||
@@ -181,18 +148,16 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> {
|
||||
itemCount: entries.length,
|
||||
itemBuilder: (context, i) {
|
||||
final entry = entries[i];
|
||||
if (entry.isDirectory) {
|
||||
return ListTile(
|
||||
leading: CenteredLeading(FileLeading(file: entry)),
|
||||
title: Text(entry.name),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _enter(bloc, state.currentPath, entry.name),
|
||||
);
|
||||
}
|
||||
return ListTile(
|
||||
enabled: false,
|
||||
enabled: entry.isDirectory,
|
||||
leading: CenteredLeading(FileLeading(file: entry)),
|
||||
title: Text(entry.name),
|
||||
trailing: entry.isDirectory
|
||||
? const Icon(Icons.chevron_right)
|
||||
: null,
|
||||
onTap: entry.isDirectory
|
||||
? () => bloc.setPath([...state.currentPath, entry.name])
|
||||
: null,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -61,15 +61,15 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
|
||||
// Captured in initState because the framework has unmounted us by the
|
||||
// time dispose runs.
|
||||
ChatBloc? _chatBlocRef;
|
||||
ChatListBloc? _chatListBlocRef;
|
||||
late final ChatBloc _chatBloc;
|
||||
late final ChatListBloc _chatListBloc;
|
||||
PageRoute<dynamic>? _subscribedRoute;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_chatBlocRef = context.read<ChatBloc>();
|
||||
_chatListBlocRef = context.read<ChatListBloc>();
|
||||
_chatBloc = context.read<ChatBloc>();
|
||||
_chatListBloc = context.read<ChatListBloc>();
|
||||
_positionsListener.itemPositions.addListener(_onScrollPositions);
|
||||
NotificationTasks.clearNotificationsForChat(widget.room.token);
|
||||
}
|
||||
@@ -83,9 +83,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
// before the user reaches the top — the load stays invisible.
|
||||
final maxIndex = positions.map((p) => p.index).reduce(math.max);
|
||||
if (maxIndex < _itemCount - _kLoadOlderPrefetchRows) return;
|
||||
final data = _chatBlocRef?.state.data;
|
||||
if (data == null || !data.hasMoreOld || data.isLoadingOlder) return;
|
||||
_chatBlocRef?.loadOlder();
|
||||
_chatBloc.loadOlder();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -107,7 +105,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
// A stacked chat above us was just popped (typical: notification tap
|
||||
// opened another chat). The global ChatBloc currently points at that
|
||||
// other chat's token, so our isReady predicate fails until we re-claim.
|
||||
_chatBlocRef?.setToken(widget.room.token);
|
||||
_chatBloc.setToken(widget.room.token);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -117,7 +115,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
}
|
||||
_positionsListener.itemPositions.removeListener(_onScrollPositions);
|
||||
_markAsReadFinal();
|
||||
_chatBlocRef?.leaveChat(widget.room.token);
|
||||
_chatBloc.leaveChat(widget.room.token);
|
||||
_searchTextController.dispose();
|
||||
Debouncer.cancel(_searchDebounceTag);
|
||||
super.dispose();
|
||||
@@ -129,15 +127,15 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
/// belongs to a different room, and writing its max-id as our marker
|
||||
/// would regress our server cursor.
|
||||
void _markAsReadFinal() {
|
||||
final state = _chatBlocRef?.state.data;
|
||||
final state = _chatBloc.state.data;
|
||||
if (state == null) return;
|
||||
if (state.currentToken != widget.room.token) return;
|
||||
final response = state.chatResponse;
|
||||
if (response == null) return;
|
||||
final maxId = response.data.map((m) => m.id).fold<int>(0, math.max);
|
||||
if (maxId == 0) return;
|
||||
_chatListBlocRef?.markRoomAsRead(widget.room.token, maxId);
|
||||
unawaited(_chatBlocRef!.sendServerReadMarker(widget.room.token, maxId));
|
||||
_chatListBloc.markRoomAsRead(widget.room.token, maxId);
|
||||
unawaited(_chatBloc.sendServerReadMarker(widget.room.token, maxId));
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -148,31 +146,34 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
}
|
||||
}
|
||||
|
||||
void _refresh() {
|
||||
context.read<ChatBloc>().setToken(widget.room.token);
|
||||
}
|
||||
|
||||
void _refetch({bool renew = false}) => _refresh();
|
||||
void _refetch() => _chatBloc.setToken(widget.room.token);
|
||||
|
||||
// The built rows only depend on the chat data and the search state; the list
|
||||
// itself rebuilds far more often (loading flips, keyboard, parent rebuilds),
|
||||
// and re-sorting plus re-creating every bubble each time is wasted work.
|
||||
// The top marker is cached separately so a loading-older flip doesn't
|
||||
// rebuild every message row.
|
||||
Object? _messagesKey;
|
||||
List<Widget> _messages = const [];
|
||||
Object? _itemsKey;
|
||||
List<Widget> _items = const [];
|
||||
|
||||
List<Widget> _itemsFor(ChatState state) {
|
||||
final key = (
|
||||
final messagesKey = (
|
||||
state.chatResponse,
|
||||
state.isLoadingOlder,
|
||||
state.hasMoreOld,
|
||||
_searchActive,
|
||||
_searchQuery,
|
||||
_activeMatchIndex,
|
||||
widget.room,
|
||||
);
|
||||
if (messagesKey != _messagesKey) {
|
||||
_messagesKey = messagesKey;
|
||||
_messages = _buildMessages(state.chatResponse!).reversed.toList();
|
||||
}
|
||||
final key = (_messages, state.isLoadingOlder, state.hasMoreOld);
|
||||
if (key == _itemsKey) return _items;
|
||||
_itemsKey = key;
|
||||
final items = _buildMessages(state.chatResponse!).reversed.toList();
|
||||
final items = [..._messages];
|
||||
// reverse:true renders index 0 at the bottom, so the top marker
|
||||
// (spinner / start-of-chat) goes at the end.
|
||||
if (state.isLoadingOlder) {
|
||||
@@ -191,26 +192,20 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
return _items = items;
|
||||
}
|
||||
|
||||
void _enterSearchMode() {
|
||||
setState(() {
|
||||
_searchActive = true;
|
||||
_searchQuery = '';
|
||||
_matches = const [];
|
||||
_activeMatchIndex = 0;
|
||||
_matchesComputedFor = null;
|
||||
_matchesComputedQuery = null;
|
||||
_searchTextController.clear();
|
||||
});
|
||||
}
|
||||
void _enterSearchMode() => _resetSearch(active: true);
|
||||
|
||||
void _exitSearchMode() {
|
||||
Debouncer.cancel(_searchDebounceTag);
|
||||
_matchIndices.clear();
|
||||
_resetSearch(active: false);
|
||||
}
|
||||
|
||||
void _resetSearch({required bool active}) {
|
||||
setState(() {
|
||||
_searchActive = false;
|
||||
_searchActive = active;
|
||||
_searchQuery = '';
|
||||
_matches = const [];
|
||||
_activeMatchIndex = 0;
|
||||
_matchIndices.clear();
|
||||
_matchesComputedFor = null;
|
||||
_matchesComputedQuery = null;
|
||||
_searchTextController.clear();
|
||||
@@ -233,7 +228,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
}
|
||||
|
||||
void _applySearch(String q) {
|
||||
final chatResponse = context.read<ChatBloc>().state.data?.chatResponse;
|
||||
final chatResponse = _chatBloc.state.data?.chatResponse;
|
||||
setState(() {
|
||||
_searchQuery = q;
|
||||
_activeMatchIndex = 0;
|
||||
@@ -260,19 +255,12 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
_matchesComputedQuery = _searchQuery;
|
||||
}
|
||||
|
||||
void _goToPreviousMatch() {
|
||||
if (_matches.isEmpty) return;
|
||||
setState(() {
|
||||
_activeMatchIndex = (_activeMatchIndex + 1) % _matches.length;
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToActiveMatch());
|
||||
}
|
||||
|
||||
void _goToNextMatch() {
|
||||
// Matches are newest-first, so "previous" (older) steps forward.
|
||||
void _stepMatch(int delta) {
|
||||
if (_matches.isEmpty) return;
|
||||
setState(() {
|
||||
_activeMatchIndex =
|
||||
(_activeMatchIndex - 1 + _matches.length) % _matches.length;
|
||||
(_activeMatchIndex + delta + _matches.length) % _matches.length;
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToActiveMatch());
|
||||
}
|
||||
@@ -379,6 +367,11 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final input = ChatTextfield(
|
||||
widget.room.token,
|
||||
selfId: widget.selfId,
|
||||
roomType: widget.room.type,
|
||||
);
|
||||
return _KeyboardDismissPopScope(
|
||||
child: Scaffold(
|
||||
backgroundColor: const Color(0xffefeae2),
|
||||
@@ -389,8 +382,8 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
activeIndex: _matches.isEmpty ? -1 : _activeMatchIndex,
|
||||
onChanged: _onSearchChanged,
|
||||
onClose: _exitSearchMode,
|
||||
onPrevious: _matches.isEmpty ? null : _goToPreviousMatch,
|
||||
onNext: _matches.isEmpty ? null : _goToNextMatch,
|
||||
onPrevious: _matches.isEmpty ? null : () => _stepMatch(1),
|
||||
onNext: _matches.isEmpty ? null : () => _stepMatch(-1),
|
||||
)
|
||||
: ClickableAppBar(
|
||||
onTap: () =>
|
||||
@@ -443,18 +436,8 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
ColoredBox(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: TalkNavigator.isSecondaryVisible(context)
|
||||
? ChatTextfield(
|
||||
widget.room.token,
|
||||
selfId: widget.selfId,
|
||||
roomType: widget.room.type,
|
||||
)
|
||||
: SafeArea(
|
||||
child: ChatTextfield(
|
||||
widget.room.token,
|
||||
selfId: widget.selfId,
|
||||
roomType: widget.room.type,
|
||||
),
|
||||
),
|
||||
? input
|
||||
: SafeArea(child: input),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -20,7 +20,7 @@ class ChatBubbleStyles {
|
||||
? const Color(0xff182229)
|
||||
: Colors.white,
|
||||
elevation: 2,
|
||||
margin: const BubbleEdges.only(bottom: 20, top: 10),
|
||||
margin: const EdgeInsets.only(bottom: 20, top: 10),
|
||||
alignment: Alignment.center,
|
||||
);
|
||||
|
||||
@@ -30,7 +30,7 @@ class ChatBubbleStyles {
|
||||
? const Color(0xff202c33)
|
||||
: Colors.white,
|
||||
elevation: 1,
|
||||
margin: const BubbleEdges.only(bottom: 10, left: 10, right: 50),
|
||||
margin: const EdgeInsets.only(bottom: 10, left: 10, right: 50),
|
||||
alignment: Alignment.topLeft,
|
||||
);
|
||||
|
||||
@@ -40,7 +40,7 @@ class ChatBubbleStyles {
|
||||
? const Color(0xff005c4b)
|
||||
: const Color(0xffd3d3d3),
|
||||
elevation: 1,
|
||||
margin: const BubbleEdges.only(bottom: 10, right: 10, left: 50),
|
||||
margin: const EdgeInsets.only(bottom: 10, right: 10, left: 50),
|
||||
alignment: Alignment.topRight,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,27 +11,33 @@ import '../widgets/highlighted_linkify.dart';
|
||||
import '../widgets/message_markdown.dart';
|
||||
|
||||
class ChatMessage {
|
||||
String originalMessage;
|
||||
Map<String, RichObjectString>? originalData;
|
||||
final String originalMessage;
|
||||
final Map<String, RichObjectString>? originalData;
|
||||
|
||||
RichObjectString? file;
|
||||
String content = '';
|
||||
final RichObjectString? file;
|
||||
final String content;
|
||||
|
||||
ChatMessage({required this.originalMessage, this.originalData}) {
|
||||
if (originalData?.containsKey('file') ?? false) {
|
||||
file = originalData?['file'];
|
||||
}
|
||||
content = RichObjectStringProcessor.parseToString(
|
||||
originalMessage,
|
||||
originalData,
|
||||
);
|
||||
}
|
||||
ChatMessage({required this.originalMessage, this.originalData})
|
||||
: file = originalData?['file'],
|
||||
content = RichObjectStringProcessor.parseToString(
|
||||
originalMessage,
|
||||
originalData,
|
||||
);
|
||||
|
||||
Widget getWidget({
|
||||
String? highlightQuery,
|
||||
TextStyle? style,
|
||||
bool renderMarkdown = false,
|
||||
}) {
|
||||
final poll = originalData?['object'];
|
||||
if (poll?.type == RichObjectStringObjectType.talkPoll) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.poll_outlined),
|
||||
title: Text(poll!.name),
|
||||
contentPadding: const EdgeInsets.only(left: 10),
|
||||
);
|
||||
}
|
||||
|
||||
final emojiFontSize = standaloneEmojiFontSize(content);
|
||||
final effectiveStyle = emojiFontSize == null
|
||||
? style
|
||||
@@ -64,14 +70,7 @@ class ChatMessage {
|
||||
);
|
||||
}
|
||||
|
||||
if (originalData?['object']?.type == RichObjectStringObjectType.talkPoll) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.poll_outlined),
|
||||
title: Text(originalData!['object']!.name),
|
||||
contentPadding: const EdgeInsets.only(left: 10),
|
||||
);
|
||||
}
|
||||
|
||||
final file = this.file;
|
||||
if (file == null) return contentWidget;
|
||||
|
||||
return Padding(
|
||||
@@ -88,7 +87,7 @@ class ChatMessage {
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Text(
|
||||
file!.name,
|
||||
file.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
@@ -107,7 +106,7 @@ class ChatMessage {
|
||||
errorListener: (value) {},
|
||||
httpHeaders: SessionManager().requireNextcloud().authHeaders,
|
||||
imageUrl:
|
||||
'https://${EndpointData().nextcloud().full()}/index.php/core/preview?fileId=${file!.id}&x=130&y=-1&a=1',
|
||||
'https://${EndpointData().nextcloud().full()}/index.php/core/preview?fileId=${file.id}&x=130&y=-1&a=1',
|
||||
),
|
||||
if (originalMessage != '{file}') ...[
|
||||
SizedBox(height: 5),
|
||||
|
||||
@@ -53,9 +53,11 @@ String markdownToPlainText(String source) {
|
||||
|
||||
final buffer = StringBuffer();
|
||||
_collect(nodes, buffer);
|
||||
return buffer.toString().replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
return buffer.toString().replaceAll(_whitespace, ' ').trim();
|
||||
}
|
||||
|
||||
final _whitespace = RegExp(r'\s+');
|
||||
|
||||
void _collect(List<md.Node> nodes, StringBuffer buffer) {
|
||||
for (final node in nodes) {
|
||||
if (node is md.Text) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import '../../../../widget/large_profile_picture_view.dart';
|
||||
import '../../../../widget/user_avatar.dart';
|
||||
import '../talk_navigator.dart';
|
||||
import '../widgets/notification_level_sheet.dart';
|
||||
import '../widgets/room_actions.dart';
|
||||
import 'participants_list_view.dart';
|
||||
import 'shared_items_view.dart';
|
||||
|
||||
@@ -82,19 +83,8 @@ class _ChatInfoState extends State<ChatInfo> {
|
||||
}
|
||||
|
||||
Future<void> _confirmLeave() async {
|
||||
final closed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => ConfirmDialog(
|
||||
title: 'Talk-Chat verlassen',
|
||||
content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.',
|
||||
confirmButton: 'Verlassen',
|
||||
onConfirmAsync: () async {
|
||||
await LeaveRoom(widget.room.token).run();
|
||||
if (mounted) _refreshList();
|
||||
},
|
||||
),
|
||||
);
|
||||
if (closed == true && mounted) Navigator.of(context).pop();
|
||||
final left = await confirmLeaveRoom(context, widget.room.token);
|
||||
if (left && mounted) Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
// Spreed's POST /room/{token}/avatar requires moderator rights and rejects
|
||||
@@ -245,18 +235,10 @@ class _ChatInfoState extends State<ChatInfo> {
|
||||
SharedItemsView(widget.room, overview: _sharesOverview),
|
||||
),
|
||||
),
|
||||
if (_isFavorite)
|
||||
AsyncListTile(
|
||||
leading: const Icon(Icons.stars_outlined),
|
||||
title: const Text('Von Favoriten entfernen'),
|
||||
onPressed: _toggleFavorite,
|
||||
)
|
||||
else
|
||||
AsyncListTile(
|
||||
leading: const Icon(Icons.star_outline),
|
||||
title: const Text('Zu Favoriten hinzufügen'),
|
||||
onPressed: _toggleFavorite,
|
||||
),
|
||||
FavoriteToggleTile(
|
||||
isFavorite: _isFavorite,
|
||||
onPressed: _toggleFavorite,
|
||||
),
|
||||
NotificationLevelTile(
|
||||
level: _notificationLevel,
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
|
||||
@@ -22,8 +22,8 @@ class ParticipantsListView extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
String lastname(participant) =>
|
||||
participant.displayName.toString().split(' ').last;
|
||||
String lastname(GetParticipantsResponseObject participant) =>
|
||||
participant.displayName.split(' ').last;
|
||||
|
||||
final participants = participantsResponse.data.sorted((a, b) {
|
||||
final typeComparison = a.participantType.index.compareTo(
|
||||
|
||||
@@ -23,19 +23,18 @@ const int _sharedItemsPageSize = 20;
|
||||
/// handled separately (split into Bilder/Videos). `location`/`deckcard` are rich
|
||||
/// objects without a downloadable file, so they have no place in this view.
|
||||
const List<_SharedCategory> _sharedCategories = [
|
||||
_SharedCategory('file', 'Dokumente', _SharedItemsLayout.list),
|
||||
_SharedCategory('voice', 'Sprachnachrichten', _SharedItemsLayout.list),
|
||||
_SharedCategory('audio', 'Audio', _SharedItemsLayout.list),
|
||||
_SharedCategory('recording', 'Aufnahmen', _SharedItemsLayout.list),
|
||||
_SharedCategory('other', 'Sonstiges', _SharedItemsLayout.list),
|
||||
_SharedCategory('file', 'Dokumente'),
|
||||
_SharedCategory('voice', 'Sprachnachrichten'),
|
||||
_SharedCategory('audio', 'Audio'),
|
||||
_SharedCategory('recording', 'Aufnahmen'),
|
||||
_SharedCategory('other', 'Sonstiges'),
|
||||
];
|
||||
|
||||
class _SharedCategory {
|
||||
final String objectType;
|
||||
final String label;
|
||||
final _SharedItemsLayout layout;
|
||||
|
||||
const _SharedCategory(this.objectType, this.label, this.layout);
|
||||
const _SharedCategory(this.objectType, this.label);
|
||||
}
|
||||
|
||||
bool _isVideoItem(GetChatResponseObject item) {
|
||||
@@ -203,7 +202,7 @@ class _SharedItemsViewState extends State<SharedItemsView>
|
||||
_SharedItemsTab(
|
||||
token: widget.room.token,
|
||||
objectType: category.objectType,
|
||||
layout: category.layout,
|
||||
layout: _SharedItemsLayout.list,
|
||||
emptyLabel: 'Keine Einträge',
|
||||
initialPage: _seedFromOverview(raw),
|
||||
),
|
||||
|
||||
@@ -11,7 +11,7 @@ import 'widgets/chat_tile.dart';
|
||||
import 'widgets/user_search_tile.dart';
|
||||
|
||||
class SearchChat extends SearchDelegate<GetRoomResponseObject?> {
|
||||
List<GetRoomResponseObject> chats;
|
||||
final List<GetRoomResponseObject> chats;
|
||||
final void Function(GetRoomResponseObject room)? onTapOverride;
|
||||
|
||||
/// When set, results are extended with a clearly separated "start a new chat"
|
||||
@@ -145,8 +145,8 @@ class _SearchChatResultsState extends State<_SearchChatResults> {
|
||||
widget.chats
|
||||
.where(
|
||||
(e) =>
|
||||
e.displayName.toString().toLowerCase().contains(query) ||
|
||||
e.name.toString().toLowerCase().contains(query),
|
||||
e.displayName.toLowerCase().contains(query) ||
|
||||
e.name.toLowerCase().contains(query),
|
||||
)
|
||||
.toList()
|
||||
..sort((a, b) => b.lastActivity.compareTo(a.lastActivity));
|
||||
|
||||
@@ -2,35 +2,14 @@ import 'package:flutter/material.dart';
|
||||
|
||||
enum BubbleNip { leftTop, rightBottom, none }
|
||||
|
||||
class BubbleEdges {
|
||||
const BubbleEdges.only({
|
||||
this.top = 0,
|
||||
this.bottom = 0,
|
||||
this.left = 0,
|
||||
this.right = 0,
|
||||
});
|
||||
const BubbleEdges.all(double value)
|
||||
: top = value,
|
||||
bottom = value,
|
||||
left = value,
|
||||
right = value;
|
||||
|
||||
final double top;
|
||||
final double bottom;
|
||||
final double left;
|
||||
final double right;
|
||||
|
||||
EdgeInsets toEdgeInsets() => EdgeInsets.fromLTRB(left, top, right, bottom);
|
||||
}
|
||||
|
||||
class BubbleStyle {
|
||||
const BubbleStyle({
|
||||
this.color,
|
||||
this.borderWidth = 0,
|
||||
this.borderColor,
|
||||
this.elevation = 0,
|
||||
this.margin = const BubbleEdges.only(),
|
||||
this.padding = const BubbleEdges.all(8),
|
||||
this.margin = EdgeInsets.zero,
|
||||
this.padding = const EdgeInsets.all(8),
|
||||
this.alignment = Alignment.centerLeft,
|
||||
this.nip = BubbleNip.none,
|
||||
this.borderRadius = 12,
|
||||
@@ -40,23 +19,24 @@ class BubbleStyle {
|
||||
final double borderWidth;
|
||||
final Color? borderColor;
|
||||
final double elevation;
|
||||
final BubbleEdges margin;
|
||||
final BubbleEdges padding;
|
||||
final EdgeInsets margin;
|
||||
final EdgeInsets padding;
|
||||
final Alignment alignment;
|
||||
final BubbleNip nip;
|
||||
final double borderRadius;
|
||||
|
||||
BubbleStyle copyWith({double? borderWidth, Color? borderColor}) => BubbleStyle(
|
||||
color: color,
|
||||
borderWidth: borderWidth ?? this.borderWidth,
|
||||
borderColor: borderColor ?? this.borderColor,
|
||||
elevation: elevation,
|
||||
margin: margin,
|
||||
padding: padding,
|
||||
alignment: alignment,
|
||||
nip: nip,
|
||||
borderRadius: borderRadius,
|
||||
);
|
||||
BubbleStyle copyWith({double? borderWidth, Color? borderColor}) =>
|
||||
BubbleStyle(
|
||||
color: color,
|
||||
borderWidth: borderWidth ?? this.borderWidth,
|
||||
borderColor: borderColor ?? this.borderColor,
|
||||
elevation: elevation,
|
||||
margin: margin,
|
||||
padding: padding,
|
||||
alignment: alignment,
|
||||
nip: nip,
|
||||
borderRadius: borderRadius,
|
||||
);
|
||||
}
|
||||
|
||||
/// The "nip" is faked by flattening one corner so the bubble anchors to
|
||||
@@ -96,7 +76,7 @@ class Bubble extends StatelessWidget {
|
||||
return Align(
|
||||
alignment: style.alignment,
|
||||
child: Container(
|
||||
margin: style.margin.toEdgeInsets(),
|
||||
margin: style.margin,
|
||||
decoration: BoxDecoration(
|
||||
color: style.color,
|
||||
borderRadius: radius,
|
||||
@@ -116,7 +96,7 @@ class Bubble extends StatelessWidget {
|
||||
]
|
||||
: null,
|
||||
),
|
||||
padding: style.padding.toEdgeInsets(),
|
||||
padding: style.padding,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -23,6 +23,10 @@ import 'highlighted_linkify.dart';
|
||||
|
||||
enum SearchHighlight { none, secondary, active }
|
||||
|
||||
const double _kSpacing = 3;
|
||||
const double _kTimeIconSize = 11;
|
||||
const Color _kTimeIconColor = Colors.grey;
|
||||
|
||||
class ChatBubble extends StatefulWidget {
|
||||
final bool isSender;
|
||||
final GetChatResponseObject bubbleData;
|
||||
@@ -30,11 +34,7 @@ class ChatBubble extends StatefulWidget {
|
||||
final bool isRead;
|
||||
final String? selfId;
|
||||
|
||||
final double spacing = 3;
|
||||
final double timeIconSize = 11;
|
||||
final Color timeIconColor = Colors.grey;
|
||||
|
||||
final void Function({bool renew}) refetch;
|
||||
final VoidCallback refetch;
|
||||
|
||||
final String? highlightQuery;
|
||||
final SearchHighlight matchHighlight;
|
||||
@@ -66,7 +66,9 @@ class _ChatBubbleState extends State<ChatBubble>
|
||||
Object? _messageKey;
|
||||
late Widget _messageWidget;
|
||||
|
||||
Offset _position = Offset.zero;
|
||||
// Only the translate offset changes per swipe frame; a notifier keeps those
|
||||
// frames from rebuilding the whole bubble.
|
||||
final ValueNotifier<Offset> _position = ValueNotifier(Offset.zero);
|
||||
Offset _dragStartPosition = Offset.zero;
|
||||
bool _swipeActionArmed = false;
|
||||
|
||||
@@ -90,6 +92,7 @@ class _ChatBubbleState extends State<ChatBubble>
|
||||
@override
|
||||
void dispose() {
|
||||
disposeDownloadTrigger();
|
||||
_position.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -165,19 +168,20 @@ class _ChatBubbleState extends State<ChatBubble>
|
||||
/// `TapGestureRecognizer` does not enter the gesture arena — otherwise
|
||||
/// it competes with (and blocks) the per-link `TapGestureRecognizer`s
|
||||
/// that `HighlightedLinkify` / the Markdown renderer attach to link spans.
|
||||
bool get _hasTapAction {
|
||||
bool get _hasTapAction => _poll != null || message.file != null;
|
||||
|
||||
RichObjectString? get _poll {
|
||||
final obj = message.originalData?['object'];
|
||||
if (obj?.type == RichObjectStringObjectType.talkPoll) return true;
|
||||
return message.file != null;
|
||||
return obj?.type == RichObjectStringObjectType.talkPoll ? obj : null;
|
||||
}
|
||||
|
||||
void _onTap() {
|
||||
final obj = message.originalData?['object'];
|
||||
if (obj?.type == RichObjectStringObjectType.talkPoll) {
|
||||
final obj = _poll;
|
||||
if (obj != null) {
|
||||
showChatBubblePollSheet(
|
||||
context,
|
||||
room: widget.chatData,
|
||||
pollId: int.parse(obj!.id),
|
||||
pollId: int.parse(obj.id),
|
||||
pollName: obj.name,
|
||||
refetch: widget.refetch,
|
||||
);
|
||||
@@ -257,10 +261,7 @@ class _ChatBubbleState extends State<ChatBubble>
|
||||
widget.bubbleData.timestamp * 1000,
|
||||
).formatHm(),
|
||||
textAlign: TextAlign.end,
|
||||
style: TextStyle(
|
||||
color: widget.timeIconColor,
|
||||
fontSize: widget.timeIconSize,
|
||||
),
|
||||
style: const TextStyle(color: _kTimeIconColor, fontSize: _kTimeIconSize),
|
||||
);
|
||||
|
||||
return Column(
|
||||
@@ -270,24 +271,23 @@ class _ChatBubbleState extends State<ChatBubble>
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onHorizontalDragStart: (_) => _dragStartPosition = _position,
|
||||
onHorizontalDragStart: (_) => _dragStartPosition = _position.value,
|
||||
onHorizontalDragUpdate: (details) {
|
||||
if (!widget.bubbleData.isReplyable) return;
|
||||
final dx = details.delta.dx - _dragStartPosition.dx;
|
||||
setState(() {
|
||||
_position = (_position.dx + dx).abs() > 60
|
||||
? Offset(_position.dx, 0)
|
||||
: Offset(_position.dx + dx, 0);
|
||||
});
|
||||
final current = _position.value.dx;
|
||||
_position.value = (current + dx).abs() > 60
|
||||
? Offset(current, 0)
|
||||
: Offset(current + dx, 0);
|
||||
// Beim Überqueren der Action-Schwelle einmalig Haptik feuern,
|
||||
// damit der User physisch spürt: "jetzt löst's beim Loslassen aus".
|
||||
final isArmed = _position.dx.abs() > 50;
|
||||
final isArmed = _position.value.dx.abs() > 50;
|
||||
if (isArmed && !_swipeActionArmed) Haptics.longPress();
|
||||
_swipeActionArmed = isArmed;
|
||||
},
|
||||
onHorizontalDragEnd: (_) {
|
||||
final isAction = _position.dx.abs() > 50;
|
||||
setState(() => _position = Offset.zero);
|
||||
final isAction = _position.value.dx.abs() > 50;
|
||||
_position.value = Offset.zero;
|
||||
_swipeActionArmed = false;
|
||||
if (widget.bubbleData.isReplyable && isAction) {
|
||||
context.read<ChatBloc>().setReferenceMessageId(
|
||||
@@ -298,8 +298,10 @@ class _ChatBubbleState extends State<ChatBubble>
|
||||
onLongPress: _showOptionsDialog,
|
||||
onDoubleTap: _showOptionsDialog,
|
||||
onTap: _hasTapAction ? _onTap : null,
|
||||
child: Transform.translate(
|
||||
offset: _position,
|
||||
child: ValueListenableBuilder<Offset>(
|
||||
valueListenable: _position,
|
||||
builder: (context, offset, child) =>
|
||||
Transform.translate(offset: offset, child: child),
|
||||
child: Bubble(
|
||||
style: _getStyle(),
|
||||
child: _BubbleContent(
|
||||
@@ -312,9 +314,6 @@ class _ChatBubbleState extends State<ChatBubble>
|
||||
isSender: widget.isSender,
|
||||
isRead: widget.isRead,
|
||||
selfId: widget.selfId,
|
||||
spacing: widget.spacing,
|
||||
timeIconSize: widget.timeIconSize,
|
||||
timeIconColor: widget.timeIconColor,
|
||||
showActorDisplayName: showActorDisplayName,
|
||||
showBubbleTime: showBubbleTime,
|
||||
downloadJob: downloadJob,
|
||||
@@ -343,9 +342,6 @@ class _BubbleContent extends StatelessWidget {
|
||||
final bool isSender;
|
||||
final bool isRead;
|
||||
final String? selfId;
|
||||
final double spacing;
|
||||
final double timeIconSize;
|
||||
final Color timeIconColor;
|
||||
final bool showActorDisplayName;
|
||||
final bool showBubbleTime;
|
||||
final DownloadJob? downloadJob;
|
||||
@@ -360,9 +356,6 @@ class _BubbleContent extends StatelessWidget {
|
||||
required this.isSender,
|
||||
required this.isRead,
|
||||
required this.selfId,
|
||||
required this.spacing,
|
||||
required this.timeIconSize,
|
||||
required this.timeIconColor,
|
||||
required this.showActorDisplayName,
|
||||
required this.showBubbleTime,
|
||||
required this.downloadJob,
|
||||
@@ -376,7 +369,7 @@ class _BubbleContent extends StatelessWidget {
|
||||
minWidth: showActorDisplayName
|
||||
? actorText.measuredWidth(context)
|
||||
: timeText.measuredWidth(context) +
|
||||
(isSender ? spacing + timeIconSize : 0) +
|
||||
(isSender ? _kSpacing + _kTimeIconSize : 0) +
|
||||
3,
|
||||
),
|
||||
child: Stack(
|
||||
@@ -409,11 +402,11 @@ class _BubbleContent extends StatelessWidget {
|
||||
children: [
|
||||
timeText,
|
||||
if (isSender) ...[
|
||||
SizedBox(width: spacing),
|
||||
const SizedBox(width: _kSpacing),
|
||||
Icon(
|
||||
isRead ? Icons.done_all_outlined : Icons.done_outlined,
|
||||
size: timeIconSize,
|
||||
color: timeIconColor,
|
||||
size: _kTimeIconSize,
|
||||
color: _kTimeIconColor,
|
||||
semanticLabel: isRead
|
||||
? A11yLabels.messageRead
|
||||
: A11yLabels.messageSent,
|
||||
|
||||
@@ -16,7 +16,7 @@ void showChatBubblePollSheet(
|
||||
required GetRoomResponseObject room,
|
||||
required int pollId,
|
||||
required String pollName,
|
||||
required void Function({bool renew}) refetch,
|
||||
required VoidCallback refetch,
|
||||
}) {
|
||||
showDetailsBottomSheet(
|
||||
context,
|
||||
@@ -36,7 +36,7 @@ void showChatBubblePollSheet(
|
||||
class _PollSheetBody extends StatefulWidget {
|
||||
final GetRoomResponseObject room;
|
||||
final int pollId;
|
||||
final void Function({bool renew}) refetch;
|
||||
final VoidCallback refetch;
|
||||
|
||||
const _PollSheetBody({
|
||||
required this.room,
|
||||
@@ -49,21 +49,13 @@ class _PollSheetBody extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _PollSheetBodyState extends State<_PollSheetBody> {
|
||||
Future<GetPollStateResponse>? _future;
|
||||
late Future<GetPollStateResponse> _future = _fetch();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = GetPollState(token: widget.room.token, pollId: widget.pollId).run();
|
||||
}
|
||||
Future<GetPollStateResponse> _fetch() =>
|
||||
GetPollState(token: widget.room.token, pollId: widget.pollId).run();
|
||||
|
||||
void _reload() {
|
||||
setState(() {
|
||||
_future = GetPollState(
|
||||
token: widget.room.token,
|
||||
pollId: widget.pollId,
|
||||
).run();
|
||||
});
|
||||
setState(() => _future = _fetch());
|
||||
}
|
||||
|
||||
void _applyState(GetPollStateResponse state) {
|
||||
@@ -71,7 +63,7 @@ class _PollSheetBodyState extends State<_PollSheetBody> {
|
||||
// Block body: an arrow would return the Future, which setState rejects.
|
||||
_future = Future.value(state);
|
||||
});
|
||||
widget.refetch(renew: true);
|
||||
widget.refetch();
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -17,7 +17,7 @@ class ChatBubbleReactions extends StatelessWidget {
|
||||
final GetChatResponseObject bubbleData;
|
||||
final GetRoomResponseObject chatData;
|
||||
final bool isSender;
|
||||
final void Function({bool renew}) onChanged;
|
||||
final VoidCallback onChanged;
|
||||
|
||||
const ChatBubbleReactions({
|
||||
required this.bubbleData,
|
||||
@@ -58,7 +58,7 @@ class ChatBubbleReactions extends StatelessWidget {
|
||||
params: ReactMessageParams(e.key),
|
||||
).run();
|
||||
}
|
||||
onChanged(renew: true);
|
||||
onChanged();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ void showChatMessageOptionsDialog(
|
||||
required GetRoomResponseObject chatData,
|
||||
required GetChatResponseObject bubbleData,
|
||||
required bool isSender,
|
||||
required void Function({bool renew}) onRefetch,
|
||||
required VoidCallback onRefetch,
|
||||
}) {
|
||||
final parentContext = context;
|
||||
final canReact =
|
||||
@@ -196,7 +196,7 @@ class _ReactionsRow extends StatefulWidget {
|
||||
final int messageId;
|
||||
final List<String> reactions;
|
||||
final List<String> messageReactions;
|
||||
final void Function({bool renew}) onRefetch;
|
||||
final VoidCallback onRefetch;
|
||||
final BuildContext sheetContext;
|
||||
|
||||
const _ReactionsRow({
|
||||
@@ -232,7 +232,7 @@ class _ReactionsRowState extends State<_ReactionsRow> {
|
||||
});
|
||||
if (!mounted) return;
|
||||
if (ok) {
|
||||
widget.onRefetch(renew: true);
|
||||
widget.onRefetch();
|
||||
if (widget.sheetContext.mounted) Navigator.of(widget.sheetContext).pop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,9 +105,7 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
||||
if (messageId != null) {
|
||||
talkSettings.draftReplies[widget.sendToToken] = messageId;
|
||||
} else {
|
||||
talkSettings.draftReplies.removeWhere(
|
||||
(key, _) => key == widget.sendToToken,
|
||||
);
|
||||
talkSettings.draftReplies.remove(widget.sendToToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,43 +283,36 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chatBloc = context.read<ChatBloc>();
|
||||
// Only the reply reference is rendered from the chat state; loading flags
|
||||
// and paging emits don't need to rebuild the input.
|
||||
final (referenceMessageId, chatResponse) = context.select(
|
||||
(ChatBloc b) => (
|
||||
b.state.data?.referenceMessageId,
|
||||
b.state.data?.chatResponse,
|
||||
),
|
||||
);
|
||||
// Only the resolved reply reference is rendered from the chat state;
|
||||
// incoming messages, loading flags and paging emits don't rebuild the input.
|
||||
final referenceMessage = context.select((ChatBloc b) {
|
||||
final data = b.state.data;
|
||||
final id = data?.referenceMessageId;
|
||||
if (id == null) return null;
|
||||
return data!.chatResponse?.data.where((e) => e.id == id).firstOrNull;
|
||||
});
|
||||
|
||||
Widget replyBanner = const SizedBox.shrink();
|
||||
if (referenceMessageId != null && chatResponse != null) {
|
||||
try {
|
||||
final referenceMessage = chatResponse.data.firstWhere(
|
||||
(e) => e.id == referenceMessageId,
|
||||
);
|
||||
replyBanner = Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AnswerReference(
|
||||
referenceMessage: referenceMessage,
|
||||
selfId: widget.selfId,
|
||||
),
|
||||
if (referenceMessage != null) {
|
||||
replyBanner = Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AnswerReference(
|
||||
referenceMessage: referenceMessage,
|
||||
selfId: widget.selfId,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Antwort verwerfen',
|
||||
onPressed: () {
|
||||
chatBloc.setReferenceMessageId(null);
|
||||
_setDraftReply(null);
|
||||
},
|
||||
icon: const Icon(Icons.close_outlined),
|
||||
padding: const EdgeInsets.only(left: 0),
|
||||
),
|
||||
],
|
||||
);
|
||||
} catch (_) {
|
||||
/* reference no longer in current chat data */
|
||||
}
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Antwort verwerfen',
|
||||
onPressed: () {
|
||||
chatBloc.setReferenceMessageId(null);
|
||||
_setDraftReply(null);
|
||||
},
|
||||
icon: const Icon(Icons.close_outlined),
|
||||
padding: const EdgeInsets.only(left: 0),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _publishHeight());
|
||||
|
||||
@@ -17,13 +17,13 @@ import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||
import '../../../../utils/haptics.dart';
|
||||
import '../../../../widget/a11y/a11y_labels.dart';
|
||||
import '../../../../widget/async_action_button.dart';
|
||||
import '../../../../widget/confirm_dialog.dart';
|
||||
import '../../../../widget/debug/debug_tile.dart';
|
||||
import '../../../../widget/details_bottom_sheet.dart';
|
||||
import '../../../../widget/user_avatar.dart';
|
||||
import '../data/talk_markdown.dart';
|
||||
import '../talk_navigator.dart';
|
||||
import 'notification_level_sheet.dart';
|
||||
import 'room_actions.dart';
|
||||
|
||||
class ChatTile extends StatefulWidget {
|
||||
final GetRoomResponseObject data;
|
||||
@@ -48,11 +48,12 @@ class ChatTile extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ChatTileState extends State<ChatTile> {
|
||||
String? selfUsername;
|
||||
String? selfUsername = SessionManager().current?.nextcloud?.username;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (selfUsername != null) return;
|
||||
SessionManager().waitForLoad().then((session) {
|
||||
if (!mounted) return;
|
||||
setState(() => selfUsername = session?.nextcloud?.username);
|
||||
@@ -124,9 +125,8 @@ class _ChatTileState extends State<ChatTile> {
|
||||
label: isGroup ? A11yLabels.group : null,
|
||||
child: ExcludeSemantics(child: circleAvatar),
|
||||
),
|
||||
Visibility(
|
||||
visible: widget.data.isFavorite,
|
||||
child: Positioned(
|
||||
if (widget.data.isFavorite)
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
@@ -143,7 +143,6 @@ class _ChatTileState extends State<ChatTile> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Row(
|
||||
@@ -241,24 +240,16 @@ class _ChatTileState extends State<ChatTile> {
|
||||
if (mounted) _refreshList();
|
||||
},
|
||||
),
|
||||
if (widget.data.isFavorite)
|
||||
AsyncListTile(
|
||||
leading: const Icon(Icons.stars_outlined),
|
||||
title: const Text('Von Favoriten entfernen'),
|
||||
onPressed: () async {
|
||||
await SetFavorite(widget.data.token, false).run();
|
||||
if (mounted) _refreshList();
|
||||
},
|
||||
)
|
||||
else
|
||||
AsyncListTile(
|
||||
leading: const Icon(Icons.star_outline),
|
||||
title: const Text('Zu Favoriten hinzufügen'),
|
||||
onPressed: () async {
|
||||
await SetFavorite(widget.data.token, true).run();
|
||||
if (mounted) _refreshList();
|
||||
},
|
||||
),
|
||||
FavoriteToggleTile(
|
||||
isFavorite: widget.data.isFavorite,
|
||||
onPressed: () async {
|
||||
await SetFavorite(
|
||||
widget.data.token,
|
||||
!widget.data.isFavorite,
|
||||
).run();
|
||||
if (mounted) _refreshList();
|
||||
},
|
||||
),
|
||||
NotificationLevelTile(
|
||||
level: widget.data.effectiveNotificationLevel,
|
||||
onTap: () {
|
||||
@@ -275,16 +266,7 @@ class _ChatTileState extends State<ChatTile> {
|
||||
title: const Text('Talk-Chat verlassen'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetCtx).pop();
|
||||
ConfirmDialog(
|
||||
title: 'Talk-Chat verlassen',
|
||||
content:
|
||||
'Du benötigst ggf. eine Einladung um erneut beizutreten.',
|
||||
confirmButton: 'Verlassen',
|
||||
onConfirmAsync: () async {
|
||||
await LeaveRoom(widget.data.token).run();
|
||||
if (mounted) _refreshList();
|
||||
},
|
||||
).asDialog(context);
|
||||
confirmLeaveRoom(context, widget.data.token);
|
||||
},
|
||||
),
|
||||
DebugTile(sheetCtx).jsonData(widget.data.toJson()),
|
||||
|
||||
@@ -62,14 +62,12 @@ class HighlightedLinkify extends StatefulWidget {
|
||||
final String? highlight;
|
||||
final LinkCallback? onOpen;
|
||||
final TextStyle? style;
|
||||
final TextStyle? linkStyle;
|
||||
|
||||
const HighlightedLinkify({
|
||||
required this.text,
|
||||
this.highlight,
|
||||
this.onOpen,
|
||||
this.style,
|
||||
this.linkStyle,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -126,11 +124,7 @@ class _HighlightedLinkifyState extends State<HighlightedLinkify> {
|
||||
// drops link color/underline because TextStyle.merge treats explicit
|
||||
// nulls in the overlay as "leave unchanged".
|
||||
final linkStyle = defaultStyle.merge(
|
||||
widget.linkStyle ??
|
||||
const TextStyle(
|
||||
color: Colors.blue,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
const TextStyle(color: Colors.blue, decoration: TextDecoration.underline),
|
||||
);
|
||||
const linkHighlight = TextStyle(
|
||||
backgroundColor: Color(0xFFFFD54F),
|
||||
|
||||
@@ -78,22 +78,12 @@ class _PollOptionsListState extends State<PollOptionsList> {
|
||||
return !(voted.length == _selected.length && voted.containsAll(_selected));
|
||||
}
|
||||
|
||||
Future<void> _submitVote() async {
|
||||
Future<void> _vote(List<int> optionIds) async {
|
||||
if (guardDemoAction(context)) return;
|
||||
final result = await VotePoll(
|
||||
token: widget.room.token,
|
||||
pollId: widget.pollData.id,
|
||||
params: VotePollParams(optionIds: _selected.toList()..sort()),
|
||||
).run();
|
||||
widget.onStateChanged(result);
|
||||
}
|
||||
|
||||
Future<void> _retractVote() async {
|
||||
if (guardDemoAction(context)) return;
|
||||
final result = await VotePoll(
|
||||
token: widget.room.token,
|
||||
pollId: widget.pollData.id,
|
||||
params: VotePollParams(optionIds: const []),
|
||||
params: VotePollParams(optionIds: optionIds),
|
||||
).run();
|
||||
widget.onStateChanged(result);
|
||||
}
|
||||
@@ -230,22 +220,22 @@ class _PollOptionsListState extends State<PollOptionsList> {
|
||||
|
||||
Widget _primaryButton() {
|
||||
final voted = widget.pollData.votedSelf.isNotEmpty;
|
||||
final canSubmit = _selected.isNotEmpty && _selectionChanged;
|
||||
|
||||
// Retract stays a flat AsyncTextButton – its spinner uses colorScheme.primary
|
||||
// and stays visible on the sheet background, unlike the filled variant's.
|
||||
if (voted && !(_selected.isNotEmpty && _selectionChanged)) {
|
||||
if (voted && !canSubmit) {
|
||||
return AsyncTextButton(
|
||||
controller: _voteController,
|
||||
onPressed: _retractVote,
|
||||
onPressed: () => _vote(const []),
|
||||
showInlineError: false,
|
||||
child: const Text('Zurückziehen'),
|
||||
);
|
||||
}
|
||||
|
||||
final canSubmit = _selected.isNotEmpty && _selectionChanged;
|
||||
return AsyncActionButton(
|
||||
controller: _voteController,
|
||||
onPressed: canSubmit ? _submitVote : null,
|
||||
onPressed: canSubmit ? () => _vote(_selected.toList()..sort()) : null,
|
||||
showInlineError: false,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../api/marianumcloud/talk/actions/talk_actions.dart';
|
||||
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||
import '../../../../widget/async_action_button.dart';
|
||||
import '../../../../widget/confirm_dialog.dart';
|
||||
|
||||
class FavoriteToggleTile extends StatelessWidget {
|
||||
final bool isFavorite;
|
||||
final AsyncActionCallback onPressed;
|
||||
|
||||
const FavoriteToggleTile({
|
||||
required this.isFavorite,
|
||||
required this.onPressed,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AsyncListTile(
|
||||
leading: Icon(isFavorite ? Icons.stars_outlined : Icons.star_outline),
|
||||
title: Text(
|
||||
isFavorite ? 'Von Favoriten entfernen' : 'Zu Favoriten hinzufügen',
|
||||
),
|
||||
onPressed: onPressed,
|
||||
);
|
||||
}
|
||||
|
||||
/// Asks for confirmation, leaves the room and refreshes the chat list.
|
||||
/// Resolves to true once the room was left.
|
||||
Future<bool> confirmLeaveRoom(BuildContext context, String token) async {
|
||||
final chatList = context.read<ChatListBloc>();
|
||||
final left = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => ConfirmDialog(
|
||||
title: 'Talk-Chat verlassen',
|
||||
content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.',
|
||||
confirmButton: 'Verlassen',
|
||||
onConfirmAsync: () async {
|
||||
await LeaveRoom(token).run();
|
||||
unawaited(chatList.refresh());
|
||||
},
|
||||
),
|
||||
);
|
||||
return left ?? false;
|
||||
}
|
||||
@@ -127,9 +127,9 @@ class _TickerScaffoldState extends State<TickerScaffold> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
final initial = widget.initialSlug;
|
||||
if (initial != null && _isSelectable(widget.sections, initial)) {
|
||||
if (initial != null && _isSelectable(initial)) {
|
||||
_selectedSlug = initial;
|
||||
_selectedTitle = _titleForSlug(initial);
|
||||
_selectedTitle = _pageFor(initial)?.title;
|
||||
} else if (initial != null) {
|
||||
// Remembered page is gone (deleted/hidden) → drop the stale slug once
|
||||
// mounted and stay on "Aktuelles".
|
||||
@@ -155,7 +155,7 @@ class _TickerScaffoldState extends State<TickerScaffold> {
|
||||
// A fresh nav can drop the open page (deleted/hidden/unpublished server
|
||||
// side) → fall back to "Aktuelles".
|
||||
final slug = _selectedSlug;
|
||||
if (slug != null && !_isSelectable(widget.sections, slug)) _selectHome();
|
||||
if (slug != null && !_isSelectable(slug)) _selectHome();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -165,13 +165,18 @@ class _TickerScaffoldState extends State<TickerScaffold> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool _isSelectable(List<TickerNavSection> sections, String slug) {
|
||||
for (final section in sections) {
|
||||
TickerNavPage? _pageFor(String slug) {
|
||||
for (final section in widget.sections) {
|
||||
for (final page in section.pages) {
|
||||
if (page.slug == slug) return page.kind != TickerPageKind.redirect;
|
||||
if (page.slug == slug) return page;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _isSelectable(String slug) {
|
||||
final page = _pageFor(slug);
|
||||
return page != null && page.kind != TickerPageKind.redirect;
|
||||
}
|
||||
|
||||
void _ensureBackEntry() {
|
||||
@@ -236,12 +241,14 @@ class _TickerScaffoldState extends State<TickerScaffold> {
|
||||
_removeBackEntry();
|
||||
}
|
||||
|
||||
void _selectPage(TickerNavPage page) {
|
||||
void _selectPage(TickerNavPage page) => _select(page.slug, page.title);
|
||||
|
||||
void _select(String slug, String? title) {
|
||||
setState(() {
|
||||
_selectedSlug = page.slug;
|
||||
_selectedTitle = page.title;
|
||||
_selectedSlug = slug;
|
||||
_selectedTitle = title;
|
||||
});
|
||||
widget.onSelectionChanged?.call(page.slug);
|
||||
widget.onSelectionChanged?.call(slug);
|
||||
_ensureBackEntry();
|
||||
}
|
||||
|
||||
@@ -255,26 +262,12 @@ class _TickerScaffoldState extends State<TickerScaffold> {
|
||||
void _onLinkTap(String href) {
|
||||
final slug = AppRoutes.tickerSlugOf(href);
|
||||
if (slug != null && slug.isNotEmpty) {
|
||||
setState(() {
|
||||
_selectedSlug = slug;
|
||||
_selectedTitle = _titleForSlug(slug);
|
||||
});
|
||||
widget.onSelectionChanged?.call(slug);
|
||||
_ensureBackEntry();
|
||||
_select(slug, _pageFor(slug)?.title);
|
||||
return;
|
||||
}
|
||||
unawaited(UrlOpener.openUrl(href));
|
||||
}
|
||||
|
||||
String? _titleForSlug(String slug) {
|
||||
for (final section in widget.sections) {
|
||||
for (final page in section.pages) {
|
||||
if (page.slug == slug) return page.title;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
|
||||
@@ -98,7 +98,9 @@ class _TickerPageContentState extends State<_TickerPageContent> {
|
||||
children: [
|
||||
// For proxied files the data currency is the last successful
|
||||
// proxy fetch, not the page's publish date.
|
||||
TickerUpdatedBar(publishedAt: page.fileFetchedAt ?? page.publishedAt),
|
||||
TickerUpdatedBar(
|
||||
publishedAt: page.fileFetchedAt ?? page.publishedAt,
|
||||
),
|
||||
Expanded(
|
||||
child: _ProxiedFileView(
|
||||
repo: context.read<TickerPageBloc>().repo,
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../../../../widget/async_action_button.dart';
|
||||
import '../../../../widget/demo_restricted.dart';
|
||||
import '../../../../widget/focus_behaviour.dart';
|
||||
import '../data/subject_color_palette.dart';
|
||||
import '../data/timetable_appointment_factory.dart';
|
||||
import '../subject_colors/subject_color_picker.dart';
|
||||
|
||||
class CustomEventEditDialog extends StatefulWidget {
|
||||
@@ -72,9 +73,10 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (_isEditing) {
|
||||
final s = widget.existingEvent!.startDate;
|
||||
final e = widget.existingEvent!.endDate;
|
||||
_isAllDay = isAllDayConvention(s, e);
|
||||
final existing = widget.existingEvent!;
|
||||
final s = existing.startDate;
|
||||
final e = existing.endDate;
|
||||
_isAllDay = TimetableAppointmentFactory.isCustomEventAllDay(existing);
|
||||
if (_isAllDay) {
|
||||
_startTime = _defaultStart;
|
||||
_endTime = _defaultEnd;
|
||||
@@ -119,21 +121,6 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
|
||||
return (fromMin(start), fromMin(end));
|
||||
}
|
||||
|
||||
/// All-day convention shared with [TimetableAppointmentFactory]: a custom
|
||||
/// event is treated as all-day when its start and end both land on midnight
|
||||
/// of the same day. We piggyback on this so we don't need a backend schema
|
||||
/// change.
|
||||
static bool isAllDayConvention(DateTime start, DateTime end) =>
|
||||
start.year == end.year &&
|
||||
start.month == end.month &&
|
||||
start.day == end.day &&
|
||||
start.hour == 0 &&
|
||||
start.minute == 0 &&
|
||||
start.second == 0 &&
|
||||
end.hour == 0 &&
|
||||
end.minute == 0 &&
|
||||
end.second == 0;
|
||||
|
||||
Future<void> _save() async {
|
||||
if (guardDemoAction(context)) return;
|
||||
if (_name.text.trim().isEmpty) {
|
||||
@@ -142,7 +129,7 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
|
||||
|
||||
// All-day convention: store start and end as midnight of the chosen day.
|
||||
// The factory recognises this on read.
|
||||
final midnight = DateTime(_date.year, _date.month, _date.day);
|
||||
final midnight = _date.dateOnly;
|
||||
final startDate = _isAllDay ? midnight : _date.withTime(_startTime);
|
||||
final endDate = _isAllDay ? midnight : _date.withTime(_endTime);
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ bool isOutsideSchoolHours(Appointment a) {
|
||||
}
|
||||
|
||||
int dayIndex(DateTime t, DateTime weekStart) =>
|
||||
DateTime(t.year, t.month, t.day).difference(weekStart).inDays;
|
||||
t.dateOnly.difference(weekStart).inDays;
|
||||
|
||||
class BoundRegion {
|
||||
final TimeRegion region;
|
||||
@@ -41,7 +41,7 @@ class BoundRegion {
|
||||
|
||||
List<BoundRegion> expandRegionsForDay(List<TimeRegion> regions, DateTime day) {
|
||||
final result = <BoundRegion>[];
|
||||
final dayStart = DateTime(day.year, day.month, day.day);
|
||||
final dayStart = day.dateOnly;
|
||||
for (final region in regions) {
|
||||
final isRecurringDaily =
|
||||
region.recurrenceRule != null &&
|
||||
@@ -123,11 +123,7 @@ partitionAppointmentsForWeek(
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
final idx = DateTime(
|
||||
occLocal.year,
|
||||
occLocal.month,
|
||||
occLocal.day,
|
||||
).difference(weekStart).inDays;
|
||||
final idx = dayIndex(occLocal, weekStart);
|
||||
if (idx < 0 || idx >= 5) continue;
|
||||
final newStart = DateTime(
|
||||
occLocal.year,
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../../../api/marianumconnect/models/mc_holiday.dart';
|
||||
import '../../../../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
|
||||
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
||||
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import '../../../../storage/timetable_settings.dart';
|
||||
import 'arbitrary_appointment.dart';
|
||||
import 'lesson_color.dart';
|
||||
@@ -150,16 +151,8 @@ class TimetableAppointmentFactory {
|
||||
List<DateTime> _holidayExceptionDates(CustomTimetableEvent event) {
|
||||
final result = <DateTime>[];
|
||||
for (final holiday in holidays) {
|
||||
var day = DateTime(
|
||||
holiday.startDate.year,
|
||||
holiday.startDate.month,
|
||||
holiday.startDate.day,
|
||||
);
|
||||
final last = DateTime(
|
||||
holiday.endDate.year,
|
||||
holiday.endDate.month,
|
||||
holiday.endDate.day,
|
||||
);
|
||||
var day = holiday.startDate.dateOnly;
|
||||
final last = holiday.endDate.dateOnly;
|
||||
while (!day.isAfter(last)) {
|
||||
result.add(
|
||||
DateTime(
|
||||
@@ -171,7 +164,7 @@ class TimetableAppointmentFactory {
|
||||
event.startDate.second,
|
||||
),
|
||||
);
|
||||
day = DateTime(day.year, day.month, day.day + 1);
|
||||
day = day.addDays(1);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -54,11 +54,8 @@ class CustomEventSheet {
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
showDeleteCustomEventDialog(context, event).future.then((
|
||||
_,
|
||||
) {
|
||||
if (!sheetCtx.mounted) return;
|
||||
Navigator.of(sheetCtx).pop();
|
||||
showDeleteCustomEventDialog(context, event).then((_) {
|
||||
if (sheetCtx.mounted) Navigator.of(sheetCtx).pop();
|
||||
});
|
||||
},
|
||||
label: const Text('Löschen'),
|
||||
|
||||
@@ -8,12 +8,13 @@ import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||
import '../../../../widget/confirm_dialog.dart';
|
||||
import '../../../../widget/demo_restricted.dart';
|
||||
|
||||
Completer<void> showDeleteCustomEventDialog(
|
||||
/// Completes once the event was deleted; never when the dialog is dismissed.
|
||||
Future<void> showDeleteCustomEventDialog(
|
||||
BuildContext context,
|
||||
CustomTimetableEvent event,
|
||||
) {
|
||||
final completer = Completer<void>();
|
||||
if (guardDemoAction(context)) return completer;
|
||||
if (guardDemoAction(context)) return completer.future;
|
||||
final bloc = context.read<TimetableBloc>();
|
||||
ConfirmDialog(
|
||||
title: 'Termin löschen',
|
||||
@@ -25,5 +26,5 @@ Completer<void> showDeleteCustomEventDialog(
|
||||
completer.complete();
|
||||
},
|
||||
).asDialog(context);
|
||||
return completer;
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
|
||||
import '../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
|
||||
import '../../../../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
|
||||
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import '../../../../extensions/text.dart';
|
||||
@@ -31,12 +32,11 @@ class LessonSheet {
|
||||
// ändern, ohne dass sich der fremde Plan aktualisiert — daher nur im
|
||||
// eigenen Plan anbieten. Die Einfärbung (eigene Farben) bleibt.
|
||||
final bloc = context.read<TimetableBloc>();
|
||||
final subjects = state.subjects?.result ?? const <McSubject>[];
|
||||
McSubject? subjectFor(String shortName) =>
|
||||
subjects.where((s) => s.shortName == shortName).firstOrNull;
|
||||
final subjectShort = lesson.subjects.firstOrNull;
|
||||
final subjectEntry = subjectShort == null
|
||||
? null
|
||||
: state.subjects?.result
|
||||
.where((s) => s.shortName == subjectShort)
|
||||
.firstOrNull;
|
||||
final subjectEntry = subjectShort == null ? null : subjectFor(subjectShort);
|
||||
final headerLong = subjectEntry?.longName;
|
||||
// Bei Stunden ohne Fach (Pausenaufsicht etc.) den Lesson-Type-Titel
|
||||
// einsetzen — sonst stünde im Header nur ein generisches "?".
|
||||
@@ -85,9 +85,7 @@ class LessonSheet {
|
||||
icon: Icons.book_outlined,
|
||||
label: 'Fächer',
|
||||
entries: lesson.subjects
|
||||
.map(
|
||||
(s) => _line(s, longname: _subjectLongName(state.subjects, s)),
|
||||
)
|
||||
.map((s) => _line(s, longname: subjectFor(s)?.longName))
|
||||
.toList(),
|
||||
),
|
||||
_roomTile(context, lesson),
|
||||
@@ -120,14 +118,10 @@ class LessonSheet {
|
||||
);
|
||||
}
|
||||
|
||||
final entries = lesson.rooms
|
||||
.map((name) => (main: _line(name), sub: null as String?))
|
||||
.toList();
|
||||
|
||||
return _listTileWithSubs(
|
||||
return _listTile(
|
||||
icon: Icons.room,
|
||||
label: lesson.rooms.length == 1 ? 'Raum' : 'Räume',
|
||||
entries: entries,
|
||||
entries: lesson.rooms.map(_line).toList(),
|
||||
trailing: trailing,
|
||||
);
|
||||
}
|
||||
@@ -229,43 +223,6 @@ class LessonSheet {
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _listTileWithSubs({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required List<({String main, String? sub})> entries,
|
||||
Widget? trailing,
|
||||
}) {
|
||||
if (entries.length == 1) {
|
||||
final e = entries.first;
|
||||
return ListTile(
|
||||
leading: Icon(icon),
|
||||
title: Text('$label: ${e.main}'),
|
||||
subtitle: e.sub != null ? Text(e.sub!) : null,
|
||||
trailing: trailing,
|
||||
);
|
||||
}
|
||||
return ListTile(
|
||||
leading: Icon(icon),
|
||||
title: Text(label),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: entries
|
||||
.expand<Widget>(
|
||||
(e) => [
|
||||
Text(e.main),
|
||||
if (e.sub != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: Text(e.sub!),
|
||||
),
|
||||
],
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
trailing: trailing,
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _listTile({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
@@ -347,15 +304,6 @@ class LessonSheet {
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
static String? _subjectLongName(dynamic subjects, String shortName) {
|
||||
if (subjects == null) return null;
|
||||
final list = subjects.result as Iterable<dynamic>;
|
||||
for (final s in list) {
|
||||
if (s.shortName == shortName) return s.longName as String?;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static IconData _iconForStatus(String status) {
|
||||
switch (status) {
|
||||
case 'CANCELLED':
|
||||
|
||||
@@ -10,8 +10,10 @@ import '../../../state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc
|
||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||
import '../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
||||
import '../../../state/app/modules/timetable/bloc/timetable_week_loader.dart';
|
||||
import '../../../utils/haptics.dart';
|
||||
import '../../../widget/demo_restricted.dart';
|
||||
import '../foreign_timetable/element_type_icon.dart';
|
||||
import 'custom_events/custom_event_edit_dialog.dart';
|
||||
import 'details/appointment_details_dispatcher.dart';
|
||||
import 'widgets/timetable_calendar_view.dart';
|
||||
@@ -33,14 +35,8 @@ class _TimetableState extends State<Timetable> {
|
||||
/// user's own. Cleared (back to own plan) via the viewing banner.
|
||||
TimetableElementRef? _selected;
|
||||
|
||||
DateTime _initialDisplayDate() => DateTime.now().addDays(2);
|
||||
|
||||
void _jumpToToday() {
|
||||
_calendarKey.currentState?.jumpToToday();
|
||||
}
|
||||
|
||||
bool _isOnInitialWeek(TimetableState state) =>
|
||||
state.startDate == _mondayOf(_initialDisplayDate());
|
||||
static bool _isOnInitialWeek(TimetableState? state) =>
|
||||
state != null && state.startDate == timetableAnchorDate().mondayOfWeek;
|
||||
|
||||
Future<void> _openPicker() async {
|
||||
if (guardDemoAction(context)) return;
|
||||
@@ -57,20 +53,7 @@ class _TimetableState extends State<Timetable> {
|
||||
setState(() => _selected = null);
|
||||
}
|
||||
|
||||
void _onAction(_CalendarAction action) {
|
||||
switch (action) {
|
||||
case _CalendarAction.addEvent:
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => const CustomEventEditDialog(),
|
||||
barrierDismissible: false,
|
||||
);
|
||||
case _CalendarAction.viewEvents:
|
||||
AppRoutes.openCustomEvents(context);
|
||||
}
|
||||
}
|
||||
|
||||
void _onCreateEventAt(DateTime start, DateTime end) {
|
||||
void _showEventEditor({DateTime? start, DateTime? end}) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) =>
|
||||
@@ -79,6 +62,15 @@ class _TimetableState extends State<Timetable> {
|
||||
);
|
||||
}
|
||||
|
||||
void _onAction(_CalendarAction action) {
|
||||
switch (action) {
|
||||
case _CalendarAction.addEvent:
|
||||
_showEventEditor();
|
||||
case _CalendarAction.viewEvents:
|
||||
AppRoutes.openCustomEvents(context);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selected = _selected;
|
||||
@@ -87,11 +79,8 @@ class _TimetableState extends State<Timetable> {
|
||||
// (or back to the own plan) tears it down and builds a fresh one.
|
||||
return BlocProvider<ForeignTimetableBloc>(
|
||||
key: ValueKey('${selected.type.name}-${selected.id}'),
|
||||
create: (_) => ForeignTimetableBloc(
|
||||
type: selected.type,
|
||||
elementId: selected.id,
|
||||
title: selected.label,
|
||||
),
|
||||
create: (_) =>
|
||||
ForeignTimetableBloc(type: selected.type, elementId: selected.id),
|
||||
// Builder gives us a context *below* the provider so the foreign bloc is
|
||||
// resolvable inside _buildForeignPlan.
|
||||
child: Builder(
|
||||
@@ -100,55 +89,68 @@ class _TimetableState extends State<Timetable> {
|
||||
);
|
||||
}
|
||||
|
||||
AppBar _appBar({
|
||||
required bool atToday,
|
||||
required bool canViewForeign,
|
||||
bool withEventMenu = false,
|
||||
}) => AppBar(
|
||||
// Der Kalender scrollt nicht (nur ziehen/reloaden), aber seine internen
|
||||
// Scrollables feuern ScrollNotifications, die sonst den Material-3
|
||||
// "scrolled under"-Farbwechsel der AppBar dauerhaft auslösen.
|
||||
notificationPredicate: (_) => false,
|
||||
title: const Text('Stunden & Vertretungsplan'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.home_outlined),
|
||||
tooltip: 'Zur aktuellen Woche',
|
||||
onPressed: atToday
|
||||
? null
|
||||
: () => _calendarKey.currentState?.jumpToToday(),
|
||||
),
|
||||
if (withEventMenu)
|
||||
PopupMenuButton<_CalendarAction>(
|
||||
tooltip: 'Kalendereinträge',
|
||||
icon: const Icon(Icons.edit_calendar_outlined),
|
||||
onSelected: _onAction,
|
||||
itemBuilder: (_) => const [
|
||||
PopupMenuItem(
|
||||
value: _CalendarAction.addEvent,
|
||||
child: ListTile(
|
||||
title: Text('Kalendereintrag hinzufügen'),
|
||||
leading: Icon(Icons.add),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _CalendarAction.viewEvents,
|
||||
child: ListTile(
|
||||
title: Text('Kalendereinträge anzeigen'),
|
||||
leading: Icon(Icons.perm_contact_calendar_outlined),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (canViewForeign)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.person_search),
|
||||
tooltip: 'Anderen Stundenplan öffnen',
|
||||
onPressed: _openPicker,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _buildOwnPlan(BuildContext context) {
|
||||
final bloc = context.read<TimetableBloc>();
|
||||
final loadableState = context.watch<TimetableBloc>().state;
|
||||
final innerState = loadableState.data;
|
||||
final atToday = innerState != null && _isOnInitialWeek(innerState);
|
||||
final atToday = context.select(
|
||||
(TimetableBloc b) => _isOnInitialWeek(b.state.data),
|
||||
);
|
||||
final capabilities = context.watch<CapabilitiesCubit>();
|
||||
final canViewForeign = capabilities.canViewForeignTimetables;
|
||||
final teacherPlan = capabilities.isTeacher;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// Der Kalender scrollt nicht (nur ziehen/reloaden), aber seine internen
|
||||
// Scrollables feuern ScrollNotifications, die sonst den Material-3
|
||||
// "scrolled under"-Farbwechsel der AppBar dauerhaft auslösen.
|
||||
notificationPredicate: (_) => false,
|
||||
title: const Text('Stunden & Vertretungsplan'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.home_outlined),
|
||||
tooltip: 'Zur aktuellen Woche',
|
||||
onPressed: atToday ? null : _jumpToToday,
|
||||
),
|
||||
PopupMenuButton<_CalendarAction>(
|
||||
tooltip: 'Kalendereinträge',
|
||||
icon: const Icon(Icons.edit_calendar_outlined),
|
||||
onSelected: _onAction,
|
||||
itemBuilder: (_) => const [
|
||||
PopupMenuItem(
|
||||
value: _CalendarAction.addEvent,
|
||||
child: ListTile(
|
||||
title: Text('Kalendereintrag hinzufügen'),
|
||||
leading: Icon(Icons.add),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _CalendarAction.viewEvents,
|
||||
child: ListTile(
|
||||
title: Text('Kalendereinträge anzeigen'),
|
||||
leading: Icon(Icons.perm_contact_calendar_outlined),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (canViewForeign)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.person_search),
|
||||
tooltip: 'Anderen Stundenplan öffnen',
|
||||
onPressed: _openPicker,
|
||||
),
|
||||
],
|
||||
appBar: _appBar(
|
||||
atToday: atToday,
|
||||
canViewForeign: canViewForeign,
|
||||
withEventMenu: true,
|
||||
),
|
||||
body: LoadableStateConsumer<TimetableBloc, TimetableState>(
|
||||
// Without this predicate the consumer treats the freshly-initialised
|
||||
@@ -170,7 +172,8 @@ class _TimetableState extends State<Timetable> {
|
||||
teacherPlan: teacherPlan,
|
||||
onOpenElement: canViewForeign ? _openElement : null,
|
||||
),
|
||||
onCreateEvent: _onCreateEventAt,
|
||||
onCreateEvent: (start, end) =>
|
||||
_showEventEditor(start: start, end: end),
|
||||
customEvents: state.customEvents?.events ?? const [],
|
||||
showClassInsteadOfTeacher: teacherPlan,
|
||||
),
|
||||
@@ -180,44 +183,21 @@ class _TimetableState extends State<Timetable> {
|
||||
|
||||
Widget _buildForeignPlan(BuildContext context, TimetableElementRef selected) {
|
||||
final bloc = context.read<ForeignTimetableBloc>();
|
||||
final loadableState = context.watch<ForeignTimetableBloc>().state;
|
||||
final innerState = loadableState.data;
|
||||
final atToday = innerState != null && _isOnInitialWeek(innerState);
|
||||
final canViewForeign = context
|
||||
.watch<CapabilitiesCubit>()
|
||||
.canViewForeignTimetables;
|
||||
final atToday = context.select(
|
||||
(ForeignTimetableBloc b) => _isOnInitialWeek(b.state.data),
|
||||
);
|
||||
final canViewForeign = context.select(
|
||||
(CapabilitiesCubit c) => c.canViewForeignTimetables,
|
||||
);
|
||||
final teacherPlan = selected.type == TimetableElementType.teacher;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// Siehe _buildOwnPlan: den scroll-under-Farbwechsel unterdrücken, weil
|
||||
// der Kalender nicht scrollt, aber ScrollNotifications feuert.
|
||||
notificationPredicate: (_) => false,
|
||||
title: const Text('Stunden & Vertretungsplan'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.home_outlined),
|
||||
tooltip: 'Zur aktuellen Woche',
|
||||
onPressed: atToday ? null : _jumpToToday,
|
||||
),
|
||||
if (canViewForeign)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.person_search),
|
||||
tooltip: 'Anderen Stundenplan öffnen',
|
||||
onPressed: _openPicker,
|
||||
),
|
||||
],
|
||||
),
|
||||
appBar: _appBar(atToday: atToday, canViewForeign: canViewForeign),
|
||||
body: Column(
|
||||
children: [
|
||||
_ViewingBanner(element: selected, onClose: _backToOwnPlan),
|
||||
Expanded(
|
||||
child: LoadableStateConsumer<ForeignTimetableBloc, TimetableState>(
|
||||
// Foreign plans never carry custom events, so unlike the own-plan
|
||||
// view we must not require `customEvents` here.
|
||||
isReady: (state) =>
|
||||
state.rooms != null &&
|
||||
state.subjects != null &&
|
||||
state.schoolHolidays != null,
|
||||
isReady: (state) => state.hasSchoolReferenceData,
|
||||
child: (state, _) => TimetableCalendarView(
|
||||
key: _calendarKey,
|
||||
state: state,
|
||||
@@ -229,7 +209,6 @@ class _TimetableState extends State<Timetable> {
|
||||
teacherPlan: teacherPlan,
|
||||
onOpenElement: canViewForeign ? _openElement : null,
|
||||
),
|
||||
customEvents: const [],
|
||||
showClassInsteadOfTeacher: teacherPlan,
|
||||
),
|
||||
),
|
||||
@@ -238,11 +217,6 @@ class _TimetableState extends State<Timetable> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static DateTime _mondayOf(DateTime d) {
|
||||
final monday = d.subtractDays(d.weekday - 1);
|
||||
return DateTime(monday.year, monday.month, monday.day);
|
||||
}
|
||||
}
|
||||
|
||||
/// Slim banner shown at the top of the timetable while a foreign element's plan
|
||||
@@ -297,7 +271,7 @@ class _ViewingBanner extends StatelessWidget {
|
||||
padding: const EdgeInsets.fromLTRB(16, 2, 6, 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(_iconFor(element.type), size: 16, color: onColor),
|
||||
Icon(element.type.icon, size: 16, color: onColor),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -324,17 +298,4 @@ class _ViewingBanner extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static IconData _iconFor(TimetableElementType type) {
|
||||
switch (type) {
|
||||
case TimetableElementType.student:
|
||||
return Icons.person_outline;
|
||||
case TimetableElementType.teacher:
|
||||
return Icons.school_outlined;
|
||||
case TimetableElementType.room:
|
||||
return Icons.meeting_room_outlined;
|
||||
case TimetableElementType.schoolClass:
|
||||
return Icons.groups_outlined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,20 +253,8 @@ class _DayColumn extends StatelessWidget {
|
||||
final period = layout.periodAtY(details.localPosition.dy);
|
||||
if (period == null) return;
|
||||
|
||||
final start = DateTime(
|
||||
date.year,
|
||||
date.month,
|
||||
date.day,
|
||||
period.start.hour,
|
||||
period.start.minute,
|
||||
);
|
||||
final end = DateTime(
|
||||
date.year,
|
||||
date.month,
|
||||
date.day,
|
||||
period.end.hour,
|
||||
period.end.minute,
|
||||
);
|
||||
final start = date.withTime(period.start);
|
||||
final end = date.withTime(period.end);
|
||||
if (_overlapsExistingAppointment(start, end, dayAppts)) return;
|
||||
|
||||
Haptics.longPress();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/// Custom 5-day work-week calendar (replaces Syncfusion's `WorkWeek` view).
|
||||
///
|
||||
/// Implementation is split across `calendar/` for readability; everything
|
||||
/// stays in this single library so private widgets and helpers (`_DayColumn`,
|
||||
/// `_PeriodLayout`, `_isAllDayLike`, …) can remain library-private.
|
||||
/// stays in this single library so private widgets (`_DayColumn`,
|
||||
/// `_WeekGrid`, …) can remain library-private.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
@@ -44,11 +44,7 @@ class SpecialRegionsBuilder {
|
||||
for (var day = rangeStart; !day.isAfter(rangeEnd); day = day.addDays(1)) {
|
||||
if (holidayDays.contains(_dayKey(day))) continue;
|
||||
for (final p in breakPeriods) {
|
||||
final start = day.copyWith(
|
||||
hour: p.start.hour,
|
||||
minute: p.start.minute,
|
||||
);
|
||||
breakRegions.add(_breakRegion(start, p.duration));
|
||||
breakRegions.add(_breakRegion(day.withTime(p.start), p.duration));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,16 +60,8 @@ class SpecialRegionsBuilder {
|
||||
// on top of each other.
|
||||
final byDay = <String, _HolidayDay>{};
|
||||
for (final holiday in holidays.result) {
|
||||
final startDay = DateTime(
|
||||
holiday.startDate.year,
|
||||
holiday.startDate.month,
|
||||
holiday.startDate.day,
|
||||
);
|
||||
final endDay = DateTime(
|
||||
holiday.endDate.year,
|
||||
holiday.endDate.month,
|
||||
holiday.endDate.day,
|
||||
);
|
||||
final startDay = holiday.startDate.dateOnly;
|
||||
final endDay = holiday.endDate.dateOnly;
|
||||
// Webuntis treats endDate inclusively (last day of the break) — the
|
||||
// `+ 1` covers single-day public holidays (where startDate == endDate)
|
||||
// and the final day of a multi-day vacation, both of which would
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../../../../extensions/date_time.dart';
|
||||
import '../../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
||||
import '../../../../state/app/modules/timetable/bloc/timetable_week_loader.dart';
|
||||
import '../data/arbitrary_appointment.dart';
|
||||
import '../data/lesson_period_schedule.dart';
|
||||
import '../data/timetable_appointment_factory.dart';
|
||||
@@ -63,12 +64,10 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
|
||||
List<TimeRegion>? _regions;
|
||||
Object? _regionsKey;
|
||||
|
||||
DateTime _initialDisplayDate() => DateTime.now().addDays(2);
|
||||
|
||||
/// Snaps the calendar back to the current week. Exposed so host pages can
|
||||
/// wire it to a "today" AppBar action.
|
||||
void jumpToToday() {
|
||||
_calendarKey.currentState?.jumpToDate(_initialDisplayDate());
|
||||
_calendarKey.currentState?.jumpToDate(timetableAnchorDate());
|
||||
}
|
||||
|
||||
List<Appointment> _appointments(TimetableState state) {
|
||||
@@ -161,7 +160,7 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
|
||||
schedule: schedule,
|
||||
appointments: appointments,
|
||||
timeRegions: regions,
|
||||
initialDate: _initialDisplayDate(),
|
||||
initialDate: timetableAnchorDate(),
|
||||
minDate: minDate,
|
||||
maxDate: maxDate,
|
||||
onAppointmentTap: widget.onAppointmentTap,
|
||||
@@ -197,16 +196,14 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
|
||||
baseMin = now.subtractDays(14);
|
||||
baseMax = now.addDays(7);
|
||||
}
|
||||
final effectiveMin = state.accessibleStartDate != null
|
||||
? (state.accessibleStartDate!.isAfter(baseMin)
|
||||
? state.accessibleStartDate!
|
||||
: baseMin)
|
||||
: baseMin;
|
||||
final effectiveMax = state.accessibleEndDate != null
|
||||
? (state.accessibleEndDate!.isBefore(baseMax)
|
||||
? state.accessibleEndDate!
|
||||
: baseMax)
|
||||
: baseMax;
|
||||
final accessibleStart = state.accessibleStartDate;
|
||||
final accessibleEnd = state.accessibleEndDate;
|
||||
final effectiveMin = accessibleStart == null
|
||||
? baseMin
|
||||
: _later(accessibleStart, baseMin);
|
||||
final effectiveMax = accessibleEnd == null
|
||||
? baseMax
|
||||
: _earlier(accessibleEnd, baseMax);
|
||||
final today = DateTime.now().dateOnly;
|
||||
final todayMonday = today.mondayOfWeek;
|
||||
final currentWeekEnd = todayMonday.addDays(DateTime.daysPerWeek - 1);
|
||||
@@ -216,12 +213,12 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
|
||||
final capMax = futureDays == null
|
||||
? null
|
||||
: _later(today.addDays(futureDays), currentWeekEnd);
|
||||
final cappedMin = capMin != null && effectiveMin.isBefore(capMin)
|
||||
? capMin
|
||||
: effectiveMin;
|
||||
final cappedMax = capMax != null && effectiveMax.isAfter(capMax)
|
||||
? capMax
|
||||
: effectiveMax;
|
||||
final cappedMin = capMin == null
|
||||
? effectiveMin
|
||||
: _later(effectiveMin, capMin);
|
||||
final cappedMax = capMax == null
|
||||
? effectiveMax
|
||||
: _earlier(effectiveMax, capMax);
|
||||
// When the resulting range does not cover the current week — the summer gap
|
||||
// between two school years, or a stale persisted bound — fall back to the
|
||||
// current week, widened to whatever the capabilities still allow. Otherwise
|
||||
|
||||
Reference in New Issue
Block a user