dart format

This commit is contained in:
2026-05-08 20:12:40 +02:00
parent 9e139b5704
commit 3b8da1d3d6
295 changed files with 6404 additions and 4161 deletions
+5 -1
View File
@@ -9,7 +9,11 @@ class BetterSortOption {
final int Function(CacheableFile, CacheableFile) compare;
final IconData icon;
BetterSortOption({required this.displayName, required this.icon, required this.compare});
BetterSortOption({
required this.displayName,
required this.icon,
required this.compare,
});
}
class SortOptions {
+34 -16
View File
@@ -25,7 +25,8 @@ class Files extends StatelessWidget {
Files({List<String>? path, super.key}) : path = path ?? [];
@override
Widget build(BuildContext context) => BlocModule<FilesBloc, LoadableState<FilesState>>(
Widget build(BuildContext context) =>
BlocModule<FilesBloc, LoadableState<FilesState>>(
create: (_) => FilesBloc(initialPath: path),
child: (context, _, _) => _FilesView(path: path),
);
@@ -51,7 +52,8 @@ class _FilesViewState extends State<_FilesView> {
// 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('/')}/';
String get _currentFolderPath =>
widget.path.isEmpty ? '' : '${widget.path.join('/')}/';
@override
void initState() {
@@ -59,7 +61,9 @@ class _FilesViewState extends State<_FilesView> {
settings = context.read<SettingsCubit>();
currentSort = settings.val().fileSettings.sortBy;
currentSortDirection = settings.val().fileSettings.ascending;
_invalidationSub = CacheInvalidationBus.listFilesStream.listen(_onInvalidation);
_invalidationSub = CacheInvalidationBus.listFilesStream.listen(
_onInvalidation,
);
}
void _onInvalidation(String invalidatedPath) {
@@ -77,15 +81,17 @@ class _FilesViewState extends State<_FilesView> {
Future<void> _mediaUpload(List<String>? paths) async {
if (paths == null) return;
final bloc = context.read<FilesBloc>();
unawaited(pushScreen(
context,
withNavBar: false,
screen: FilesUploadDialog(
filePaths: paths,
remotePath: widget.path.join('/'),
onUploadFinished: (_) => bloc.refresh(),
unawaited(
pushScreen(
context,
withNavBar: false,
screen: FilesUploadDialog(
filePaths: paths,
remotePath: widget.path.join('/'),
onUploadFinished: (_) => bloc.refresh(),
),
),
));
);
}
@override
@@ -116,29 +122,41 @@ class _FilesViewState extends State<_FilesView> {
floatingActionButton: FloatingActionButton(
heroTag: 'uploadFile',
backgroundColor: Theme.of(context).primaryColor,
onPressed: () => showAddFileSheet(context, bloc: bloc, onPickedFiles: _mediaUpload),
onPressed: () =>
showAddFileSheet(context, bloc: bloc, onPickedFiles: _mediaUpload),
child: const Icon(Icons.add),
),
body: Column(
children: [
ClipboardBanner(currentFolder: _currentFolderPath, onPasteDone: bloc.refresh),
ClipboardBanner(
currentFolder: _currentFolderPath,
onPasteDone: bloc.refresh,
),
Expanded(
child: LoadableStateConsumer<FilesBloc, FilesState>(
isReady: (state) => state.listing != null,
child: (state, _) {
final listing = state.listing!;
if (listing.files.isEmpty) {
return const PlaceholderView(icon: Icons.folder_off_rounded, text: 'Der Ordner ist leer');
return const PlaceholderView(
icon: Icons.folder_off_rounded,
text: 'Der Ordner ist leer',
);
}
final files = listing.sortBy(
sortOption: currentSort,
foldersToTop: context.watch<SettingsCubit>().val().fileSettings.sortFoldersToTop,
foldersToTop: context
.watch<SettingsCubit>()
.val()
.fileSettings
.sortFoldersToTop,
reversed: currentSortDirection,
);
return ListView.builder(
padding: EdgeInsets.zero,
itemCount: files.length,
itemBuilder: (context, index) => FileElement(files[index], widget.path, bloc.refresh),
itemBuilder: (context, index) =>
FileElement(files[index], widget.path, bloc.refresh),
);
},
),
+190 -155
View File
@@ -15,7 +15,13 @@ class FilesUploadDialog extends StatefulWidget {
final void Function(List<String> uploadedFilePaths) onUploadFinished;
final bool uniqueNames;
const FilesUploadDialog({super.key, required this.filePaths, required this.remotePath, required this.onUploadFinished, this.uniqueNames = false});
const FilesUploadDialog({
super.key,
required this.filePaths,
required this.remotePath,
required this.onUploadFinished,
this.uniqueNames = false,
});
@override
State<FilesUploadDialog> createState() => _FilesUploadDialogState();
@@ -31,7 +37,6 @@ class UploadableFile {
UploadableFile(this.filePath, this.fileName);
}
class _FilesUploadDialogState extends State<FilesUploadDialog> {
late List<UploadableFile> _uploadableFiles;
bool _isUploading = false;
@@ -63,7 +68,12 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
_overallProgressValue = 0.0;
_infoText = '';
});
InfoDialog.show(context, message, title: 'Upload fehlgeschlagen', copyable: true);
InfoDialog.show(
context,
message,
title: 'Upload fehlgeschlagen',
copyable: true,
);
}
Future<void> uploadFiles({bool override = false}) async {
@@ -80,7 +90,9 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
if (!override) {
List<dynamic> result;
try {
result = (await webdavClient.propfind(PathUri.parse(widget.remotePath))).responses;
result = (await webdavClient.propfind(
PathUri.parse(widget.remotePath),
)).responses;
} catch (e) {
if (!mounted) return;
_showUploadError('Verbindung fehlgeschlagen: $e');
@@ -88,7 +100,11 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
}
final conflictingFiles = _uploadableFiles.where((file) {
final fileName = file.fileName;
return result.any((element) => Uri.decodeComponent((element as WebDavResponse).href!).endsWith('/$fileName'));
return result.any(
(element) => Uri.decodeComponent(
(element as WebDavResponse).href!,
).endsWith('/$fileName'),
);
}).toList();
if (conflictingFiles.isNotEmpty) {
@@ -97,46 +113,46 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
context: context,
barrierDismissible: false,
builder: (context) => 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.',
textAlign: TextAlign.left,
) :
SingleChildScrollView(
child: Text(
'${conflictingFiles.length} Dateien mit folgenden Namen existieren bereits: \n${conflictingFiles.map((e) => '\n - ${e.fileName}').join('')}',
textAlign: TextAlign.left,
),
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.',
textAlign: TextAlign.left,
)
: SingleChildScrollView(
child: Text(
'${conflictingFiles.length} Dateien mit folgenden Namen existieren bereits: \n${conflictingFiles.map((e) => '\n - ${e.fileName}').join('')}',
textAlign: TextAlign.left,
),
),
actions: [
TextButton(
onPressed: () {
Navigator.pop(context, false);
},
child: const Text('Bearbeiten', textAlign: TextAlign.center),
),
actions: [
TextButton(
onPressed: () {
Navigator.pop(context, 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',
),
);
},
child: const Text('Überschreiben', 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',
),
);
},
child: const Text('Überschreiben', textAlign: TextAlign.center),
),
],
),
);
if (replaceFiles != true) {
@@ -160,13 +176,15 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
if (widget.uniqueNames) {
final unique = DateTime.now().microsecondsSinceEpoch.toRadixString(36);
fileName = '${fileName.split('.').first}-$unique.${fileName.split('.').last}';
fileName =
'${fileName.split('.').first}-$unique.${fileName.split('.').last}';
}
var fullRemotePath = '${widget.remotePath}/$fileName';
setState(() {
_infoText = '${_uploadableFiles.indexOf(file) + 1}/${_uploadableFiles.length}';
_infoText =
'${_uploadableFiles.indexOf(file) + 1}/${_uploadableFiles.length}';
});
final HttpClientResponse uploadTask;
@@ -178,7 +196,10 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
onProgress: (progress) {
setState(() {
file._uploadProgress = progress;
_overallProgressValue = ((progress + _uploadableFiles.indexOf(file)) / _uploadableFiles.length).toDouble();
_overallProgressValue =
((progress + _uploadableFiles.indexOf(file)) /
_uploadableFiles.length)
.toDouble();
});
},
);
@@ -188,7 +209,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
return;
}
if(uploadTask.statusCode < 200 || uploadTask.statusCode > 299) {
if (uploadTask.statusCode < 200 || uploadTask.statusCode > 299) {
setState(() {
_isUploading = false;
_overallProgressValue = 0.0;
@@ -214,119 +235,133 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('Dateien hochladen'),
automaticallyImplyLeading: false,
),
body: LoaderOverlay(
overlayWholeScreen: true,
child: Column(
children: [
Expanded(
child: ListView.builder(
itemCount: _uploadableFiles.length,
itemBuilder: (context, index) {
final currentFile = _uploadableFiles[index];
currentFile.fileNameController.text = currentFile.fileName;
return ListTile(
title: TextField(
readOnly: _isUploading,
controller: currentFile.fileNameController,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
label: Text('Datei ${index+1}'),
errorText: currentFile.isConflicting ? 'existiert bereits' : null,
errorStyle: TextStyle(color: Theme.of(context).colorScheme.error),
appBar: AppBar(
title: const Text('Dateien hochladen'),
automaticallyImplyLeading: false,
),
body: LoaderOverlay(
overlayWholeScreen: true,
child: Column(
children: [
Expanded(
child: ListView.builder(
itemCount: _uploadableFiles.length,
itemBuilder: (context, index) {
final currentFile = _uploadableFiles[index];
currentFile.fileNameController.text = currentFile.fileName;
return ListTile(
title: TextField(
readOnly: _isUploading,
controller: currentFile.fileNameController,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
label: Text('Datei ${index + 1}'),
errorText: currentFile.isConflicting
? 'existiert bereits'
: null,
errorStyle: TextStyle(
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;
});
}
},
),
subtitle: _isUploading && (currentFile._uploadProgress ?? 0) < 1 ? LinearProgressIndicator(
value: currentFile._uploadProgress,
borderRadius: const BorderRadius.all(Radius.circular(2)),
) : null,
trailing: Container(
width: 24,
height: 24,
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;
});
}
},
),
subtitle:
_isUploading && (currentFile._uploadProgress ?? 0) < 1
? LinearProgressIndicator(
value: currentFile._uploadProgress,
borderRadius: const BorderRadius.all(
Radius.circular(2),
),
)
: null,
trailing: Container(
width: 24,
height: 24,
padding: EdgeInsets.zero,
child: IconButton(
tooltip: 'Datei entfernen',
padding: EdgeInsets.zero,
child: IconButton(
tooltip: 'Datei entfernen',
padding: EdgeInsets.zero,
onPressed: () {
if(!_isUploading) {
if(_uploadableFiles.length-1 <= 0) Navigator.of(context).pop();
setState(() {
_uploadableFiles.removeAt(index);
});
onPressed: () {
if (!_isUploading) {
if (_uploadableFiles.length - 1 <= 0) {
Navigator.of(context).pop();
}
},
icon: const Icon(Icons.delete_outlined),
),
),
);
},
),
),
Padding(
padding: const EdgeInsets.only(left: 15, right: 15, bottom: 15, top: 5),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Visibility(
visible: !_isUploading,
child: TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Abbrechen'),
setState(() {
_uploadableFiles.removeAt(index);
});
}
},
icon: const Icon(Icons.delete_outlined),
),
),
const Expanded(child: SizedBox.shrink()),
Visibility(
visible: _isUploading,
replacement: TextButton(
onPressed: () => uploadFiles(override: widget.uniqueNames),
child: const Text('Hochladen'),
),
child: Visibility(
visible: _infoText.length < 5,
replacement: Row(
children: [
Text(_infoText),
const SizedBox(width: 15),
CircularProgressIndicator(value: _overallProgressValue),
],
),
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(value: _overallProgressValue),
Center(child: Text(_infoText)),
],
),
),
),
],
),
);
},
),
],
),
),
Padding(
padding: const EdgeInsets.only(
left: 15,
right: 15,
bottom: 15,
top: 5,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Visibility(
visible: !_isUploading,
child: TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Abbrechen'),
),
),
const Expanded(child: SizedBox.shrink()),
Visibility(
visible: _isUploading,
replacement: TextButton(
onPressed: () => uploadFiles(override: widget.uniqueNames),
child: const Text('Hochladen'),
),
child: Visibility(
visible: _infoText.length < 5,
replacement: Row(
children: [
Text(_infoText),
const SizedBox(width: 15),
CircularProgressIndicator(value: _overallProgressValue),
],
),
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(value: _overallProgressValue),
Center(child: Text(_infoText)),
],
),
),
),
],
),
),
],
),
);
),
);
}
@@ -50,7 +50,11 @@ class _ClipboardBannerState extends State<ClipboardBanner> {
final src = _normalised(f.path);
if (dst == src || dst.startsWith(src)) return false;
}
final destination = _joinPath(widget.currentFolder, f.name, isDirectory: f.isDirectory);
final destination = _joinPath(
widget.currentFolder,
f.name,
isDirectory: f.isDirectory,
);
if (destination != f.path) atLeastOneActionable = true;
}
return atLeastOneActionable;
@@ -75,14 +79,24 @@ class _ClipboardBannerState extends State<ClipboardBanner> {
try {
final webdav = await WebdavApi.webdav;
for (final file in cb.files) {
final destination = _joinPath(widget.currentFolder, file.name, isDirectory: file.isDirectory);
final destination = _joinPath(
widget.currentFolder,
file.name,
isDirectory: file.isDirectory,
);
if (destination == file.path) continue;
try {
if (operation == FileClipboardOperation.cut) {
await webdav.move(PathUri.parse(file.path), PathUri.parse(destination));
await webdav.move(
PathUri.parse(file.path),
PathUri.parse(destination),
);
invalidatedSourceFolders.add(_parentCacheKey(file.path));
} else {
await webdav.copy(PathUri.parse(file.path), PathUri.parse(destination));
await webdav.copy(
PathUri.parse(file.path),
PathUri.parse(destination),
);
}
} on Object catch (e) {
errors.add('${file.name}: $e');
@@ -111,42 +125,49 @@ class _ClipboardBannerState extends State<ClipboardBanner> {
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: FileClipboard.instance,
builder: (context, _) {
final cb = FileClipboard.instance;
if (cb.isEmpty) return const SizedBox.shrink();
final cut = cb.operation == FileClipboardOperation.cut;
final count = cb.files.length;
final label = count == 1 ? '"${cb.files.first.name}"' : '$count Elemente';
return Material(
color: Theme.of(context).colorScheme.secondaryContainer,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Icon(cut ? Icons.drive_file_move_outline : Icons.copy_outlined, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
cut ? '$label verschieben' : '$label kopieren',
overflow: TextOverflow.ellipsis,
),
),
TextButton(
onPressed: _busy || !_canPaste ? null : _paste,
child: _busy
? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Hier einfügen'),
),
IconButton(
tooltip: 'Verwerfen',
icon: const Icon(Icons.close, size: 20),
onPressed: _busy ? null : cb.clear,
),
],
listenable: FileClipboard.instance,
builder: (context, _) {
final cb = FileClipboard.instance;
if (cb.isEmpty) return const SizedBox.shrink();
final cut = cb.operation == FileClipboardOperation.cut;
final count = cb.files.length;
final label = count == 1 ? '"${cb.files.first.name}"' : '$count Elemente';
return Material(
color: Theme.of(context).colorScheme.secondaryContainer,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Icon(
cut ? Icons.drive_file_move_outline : Icons.copy_outlined,
size: 20,
),
),
);
},
const SizedBox(width: 12),
Expanded(
child: Text(
cut ? '$label verschieben' : '$label kopieren',
overflow: TextOverflow.ellipsis,
),
),
TextButton(
onPressed: _busy || !_canPaste ? null : _paste,
child: _busy
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Hier einfügen'),
),
IconButton(
tooltip: 'Verwerfen',
icon: const Icon(Icons.close, size: 20),
onPressed: _busy ? null : cb.clear,
),
],
),
),
);
},
);
}
@@ -12,49 +12,67 @@ void showFileDetailsSheet(BuildContext context, CacheableFile file) {
showDetailsBottomSheet(
context,
header: ListTile(
leading: Icon(file.isDirectory ? Icons.folder : Icons.description_outlined, size: 32),
title: Text(file.name, style: const TextStyle(fontWeight: FontWeight.bold)),
leading: Icon(
file.isDirectory ? Icons.folder : Icons.description_outlined,
size: 32,
),
title: Text(
file.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(file.isDirectory ? 'Ordner' : (file.mimeType ?? '')),
),
children: (_) => [
_DetailRow(label: 'Pfad', value: file.path, copyable: true),
if (!file.isDirectory) _DetailRow(label: 'Größe', value: filesize(file.size)),
if (!file.isDirectory)
_DetailRow(label: 'Größe', value: filesize(file.size)),
if (file.modifiedAt != null)
_DetailRow(
label: 'Geändert',
value: '${file.modifiedAt!.formatDateTime()} (${file.modifiedAt!.formatRelative()})',
value:
'${file.modifiedAt!.formatDateTime()} (${file.modifiedAt!.formatRelative()})',
),
if (file.createdAt != null)
_DetailRow(label: 'Erstellt', value: file.createdAt!.formatDateTime()),
if (file.eTag != null) _DetailRow(label: 'ETag', value: file.eTag!, copyable: true),
if (file.eTag != null)
_DetailRow(label: 'ETag', value: file.eTag!, copyable: true),
],
);
}
class _DetailRow extends StatelessWidget {
const _DetailRow({required this.label, required this.value, this.copyable = false});
const _DetailRow({
required this.label,
required this.value,
this.copyable = false,
});
final String label;
final String value;
final bool copyable;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 90,
child: Text(label, style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 90,
child: Text(
label,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
Expanded(child: SelectableText(value)),
if (copyable)
IconButton(
tooltip: 'Kopieren',
icon: const Icon(Icons.copy, size: 18),
onPressed: () => copyToClipboard(context, value),
),
],
),
),
);
Expanded(child: SelectableText(value)),
if (copyable)
IconButton(
tooltip: 'Kopieren',
icon: const Icon(Icons.copy, size: 18),
onPressed: () => copyToClipboard(context, value),
),
],
),
);
}
+47 -21
View File
@@ -138,11 +138,17 @@ class _FileElementState extends State<FileElement> {
void _onTap() {
if (widget.file.isDirectory) {
AppRoutes.openFolder(context, widget.path.toList()..add(widget.file.name));
AppRoutes.openFolder(
context,
widget.path.toList()..add(widget.file.name),
);
return;
}
if (EndpointData().getEndpointMode() == EndpointMode.stage) {
InfoDialog.show(context, 'Virtuelle Dateien im Staging Prozess können nicht heruntergeladen werden!');
InfoDialog.show(
context,
'Virtuelle Dateien im Staging Prozess können nicht heruntergeladen werden!',
);
return;
}
final status = _job?.status.value;
@@ -178,21 +184,34 @@ class _FileElementState extends State<FileElement> {
autofocus: true,
),
actions: [
TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('Abbrechen')),
TextButton(
onPressed: () => Navigator.of(dialogCtx).pop(controller.text.trim()),
onPressed: () => Navigator.of(dialogCtx).pop(),
child: const Text('Abbrechen'),
),
TextButton(
onPressed: () =>
Navigator.of(dialogCtx).pop(controller.text.trim()),
child: const Text('Umbenennen'),
),
],
),
);
if (newName == null || newName.isEmpty || newName == widget.file.name) return;
if (newName == null || newName.isEmpty || newName == widget.file.name) {
return;
}
final parent = _parentPathOf(widget.file.path);
final destination = _joinPath(parent, newName, isDirectory: widget.file.isDirectory);
final destination = _joinPath(
parent,
newName,
isDirectory: widget.file.isDirectory,
);
await _runWebdavOp(() async {
final webdav = await WebdavApi.webdav;
await webdav.move(PathUri.parse(widget.file.path), PathUri.parse(destination));
await webdav.move(
PathUri.parse(widget.file.path),
PathUri.parse(destination),
);
}, errorTitle: 'Umbenennen fehlgeschlagen');
} finally {
controller.dispose();
@@ -205,10 +224,14 @@ class _FileElementState extends State<FileElement> {
} else {
FileClipboard.instance.cut([widget.file]);
}
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('"${widget.file.name}" zum ${copy ? "Kopieren" : "Verschieben"} bereitgelegt'),
duration: const Duration(seconds: 2),
));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'"${widget.file.name}" zum ${copy ? "Kopieren" : "Verschieben"} bereitgelegt',
),
duration: const Duration(seconds: 2),
),
);
}
Future<void> _delete() async {
@@ -227,7 +250,10 @@ class _FileElementState extends State<FileElement> {
);
}
Future<void> _runWebdavOp(Future<void> Function() action, {required String errorTitle}) async {
Future<void> _runWebdavOp(
Future<void> Function() action, {
required String errorTitle,
}) async {
try {
await action();
widget.refetch();
@@ -287,13 +313,13 @@ class _FileElementState extends State<FileElement> {
@override
Widget build(BuildContext context) => ListTile(
leading: CenteredLeading(
Icon(widget.file.isDirectory ? Icons.folder : Icons.description_outlined),
),
title: Text(widget.file.name, maxLines: 2, overflow: TextOverflow.ellipsis),
subtitle: _subtitle(),
trailing: Icon(widget.file.isDirectory ? Icons.arrow_right : null),
onTap: _onTap,
onLongPress: _showActionSheet,
);
leading: CenteredLeading(
Icon(widget.file.isDirectory ? Icons.folder : Icons.description_outlined),
),
title: Text(widget.file.name, maxLines: 2, overflow: TextOverflow.ellipsis),
subtitle: _subtitle(),
trailing: Icon(widget.file.isDirectory ? Icons.arrow_right : null),
onTap: _onTap,
onLongPress: _showActionSheet,
);
}
@@ -23,37 +23,48 @@ class FilesSortActions extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
PopupMenuButton<bool>(
icon: Icon(ascending ? Icons.text_rotate_up : Icons.text_rotation_down),
icon: Icon(
ascending ? Icons.text_rotate_up : Icons.text_rotation_down,
),
itemBuilder: (context) => [true, false]
.map((e) => PopupMenuItem<bool>(
value: e,
enabled: e != ascending,
child: Row(
children: [
Icon(e ? Icons.text_rotate_up : Icons.text_rotation_down,
color: theme.colorScheme.onSurface),
const SizedBox(width: 15),
Text(e ? 'Aufsteigend' : 'Absteigend'),
],
),
))
.map(
(e) => PopupMenuItem<bool>(
value: e,
enabled: e != ascending,
child: Row(
children: [
Icon(
e ? Icons.text_rotate_up : Icons.text_rotation_down,
color: theme.colorScheme.onSurface,
),
const SizedBox(width: 15),
Text(e ? 'Aufsteigend' : 'Absteigend'),
],
),
),
)
.toList(),
onSelected: onDirectionChanged,
),
PopupMenuButton<SortOption>(
icon: const Icon(Icons.sort),
itemBuilder: (context) => SortOptions.options.keys
.map((key) => PopupMenuItem<SortOption>(
value: key,
enabled: key != currentSort,
child: Row(
children: [
Icon(SortOptions.getOption(key).icon, color: theme.colorScheme.onSurface),
const SizedBox(width: 15),
Text(SortOptions.getOption(key).displayName),
],
),
))
.map(
(key) => PopupMenuItem<SortOption>(
value: key,
enabled: key != currentSort,
child: Row(
children: [
Icon(
SortOptions.getOption(key).icon,
color: theme.colorScheme.onSurface,
),
const SizedBox(width: 15),
Text(SortOptions.getOption(key).displayName),
],
),
),
)
.toList(),
onSelected: onSortChanged,
),
@@ -12,7 +12,7 @@ class GradeAveragesListView extends StatelessWidget {
var bloc = context.watch<GradeAveragesBloc>();
String getGradeDisplay(int grade) {
if(bloc.isMiddleSchool()) {
if (bloc.isMiddleSchool()) {
return 'Note $grade';
} else {
return "$grade Punkt${grade > 1 ? "e" : ""}";
@@ -25,7 +25,9 @@ class GradeAveragesListView extends StatelessWidget {
var grade = bloc.getGradeFromIndex(index);
return Material(
child: ListTile(
tileColor: grade.isEven ? Colors.transparent : Colors.transparent.withAlpha(50),
tileColor: grade.isEven
? Colors.transparent
: Colors.transparent.withAlpha(50),
title: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -39,7 +41,13 @@ class GradeAveragesListView extends StatelessWidget {
icon: const Icon(Icons.remove),
color: Theme.of(context).colorScheme.onSurface,
),
Text('${bloc.countOfGrade(grade)}', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
Text(
'${bloc.countOfGrade(grade)}',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
IconButton(
onPressed: () {
bloc.add(IncrementGrade(grade));
@@ -12,49 +12,56 @@ class GradeAveragesView extends StatelessWidget {
@override
Widget build(BuildContext context) => BlocProvider<GradeAveragesBloc>(
create: (context) => GradeAveragesBloc(),
child: BlocBuilder<GradeAveragesBloc, GradeAveragesState>(
builder: (context, state) {
var bloc = context.watch<GradeAveragesBloc>();
create: (context) => GradeAveragesBloc(),
child: BlocBuilder<GradeAveragesBloc, GradeAveragesState>(
builder: (context, state) {
var bloc = context.watch<GradeAveragesBloc>();
return Scaffold(
return Scaffold(
appBar: AppBar(
title: const Text('Notendurschnittsrechner'),
actions: [
Visibility(
visible: bloc.state.grades.isNotEmpty,
child: IconButton(
onPressed: () {
showDialog(
context: context,
builder: (context) => ConfirmDialog(
title: 'Zurücksetzen?',
content: 'Alle Einträge werden entfernt.',
confirmButton: 'Zurücksetzen',
onConfirm: () {
bloc.add(ResetAll());
},
),
);
},
icon: const Icon(Icons.delete_forever)),
onPressed: () {
showDialog(
context: context,
builder: (context) => ConfirmDialog(
title: 'Zurücksetzen?',
content: 'Alle Einträge werden entfernt.',
confirmButton: 'Zurücksetzen',
onConfirm: () {
bloc.add(ResetAll());
},
),
);
},
icon: const Icon(Icons.delete_forever),
),
),
PopupMenuButton<bool>(
initialValue: bloc.isMiddleSchool(),
icon: const Icon(Icons.more_horiz),
itemBuilder: (context) => [true, false].map((isMiddleSchool) => PopupMenuItem<bool>(
value: isMiddleSchool,
child: Row(
children: [
Icon(
isMiddleSchool ? Icons.calculate_outlined : Icons.school_outlined,
color: Theme.of(context).colorScheme.onSurface
itemBuilder: (context) => [true, false]
.map(
(isMiddleSchool) => PopupMenuItem<bool>(
value: isMiddleSchool,
child: Row(
children: [
Icon(
isMiddleSchool
? Icons.calculate_outlined
: Icons.school_outlined,
color: Theme.of(context).colorScheme.onSurface,
),
const SizedBox(width: 15),
Text(isMiddleSchool ? 'Realschule' : 'Oberstufe'),
],
),
),
const SizedBox(width: 15),
Text(isMiddleSchool ? 'Realschule' : 'Oberstufe'),
],
),
)).toList(),
)
.toList(),
onSelected: (isMiddleSchool) {
if (bloc.state.grades.isNotEmpty) {
showDialog(
@@ -62,9 +69,10 @@ class GradeAveragesView extends StatelessWidget {
builder: (context) => ConfirmDialog(
title: 'Notensystem wechseln',
content:
'Beim Wechsel des Notensystems werden alle Einträge zurückgesetzt.',
'Beim Wechsel des Notensystems werden alle Einträge zurückgesetzt.',
confirmButton: 'Fortfahren',
onConfirm: () => bloc.add(GradingSystemChanged(isMiddleSchool)),
onConfirm: () =>
bloc.add(GradingSystemChanged(isMiddleSchool)),
),
);
} else {
@@ -84,23 +92,34 @@ class GradeAveragesView extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Ø', style: TextStyle(fontSize: 30, fontWeight: FontWeight.bold)),
Text(
'Ø',
style: TextStyle(fontSize: 30, fontWeight: FontWeight.bold),
),
SizedBox(width: 5),
Text(bloc.average().toStringAsFixed(2), style: const TextStyle(fontSize: 30, fontWeight: FontWeight.bold))
Text(
bloc.average().toStringAsFixed(2),
style: const TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 10),
const Divider(),
const SizedBox(height: 10),
Text(bloc.isMiddleSchool() ? 'Wähle die Anzahl deiner jeweiligen Noten aus' : 'Wähle die Anzahl deiner jeweiligen Punkte aus'),
const SizedBox(height: 10),
const Expanded(
child: GradeAveragesListView()
Text(
bloc.isMiddleSchool()
? 'Wähle die Anzahl deiner jeweiligen Noten aus'
: 'Wähle die Anzahl deiner jeweiligen Punkte aus',
),
const SizedBox(height: 10),
const Expanded(child: GradeAveragesListView()),
],
),
);
},
),
);
},
),
);
}
+98 -65
View File
@@ -19,17 +19,19 @@ class HolidaysView extends StatelessWidget {
const HolidaysView({super.key});
@override
Widget build(BuildContext context) => BlocModule<HolidaysBloc, LoadableState<HolidaysState>>(
Widget build(
BuildContext context,
) => BlocModule<HolidaysBloc, LoadableState<HolidaysState>>(
create: (context) => HolidaysBloc(),
autoRebuild: true,
child: (context, bloc, state) {
void showDisclaimer() => InfoDialog.show(
context,
'Sämtliche Datumsangaben sind ohne Gewähr.\n'
'Ich übernehme weder Verantwortung für die Richtigkeit der Daten noch hafte ich für wirtschaftliche Schäden die aus der Verwendung dieser Daten entstehen können.\n\n'
'Die Daten stammen von https://ferien-api.de/',
title: 'Richtigkeit und Bereitstellung der Daten',
);
context,
'Sämtliche Datumsangaben sind ohne Gewähr.\n'
'Ich übernehme weder Verantwortung für die Richtigkeit der Daten noch hafte ich für wirtschaftliche Schäden die aus der Verwendung dieser Daten entstehen können.\n\n'
'Die Daten stammen von https://ferien-api.de/',
title: 'Richtigkeit und Bereitstellung der Daten',
);
return Scaffold(
appBar: AppBar(
@@ -42,79 +44,110 @@ class HolidaysView extends StatelessWidget {
PopupMenuButton<bool>(
initialValue: bloc.showPastHolidays(),
icon: const Icon(Icons.history),
itemBuilder: (context) => [true, false].map((e) => PopupMenuItem<bool>(
value: e,
enabled: e != bloc.showPastHolidays(),
child: Row(
children: [
Icon(e ? Icons.history_outlined : Icons.history_toggle_off_outlined, color: Theme.of(context).colorScheme.onSurface),
const SizedBox(width: 15),
Text(e ? 'Alle anzeigen' : 'Nur zukünftige anzeigen')
],
itemBuilder: (context) => [true, false]
.map(
(e) => PopupMenuItem<bool>(
value: e,
enabled: e != bloc.showPastHolidays(),
child: Row(
children: [
Icon(
e
? Icons.history_outlined
: Icons.history_toggle_off_outlined,
color: Theme.of(context).colorScheme.onSurface,
),
const SizedBox(width: 15),
Text(e ? 'Alle anzeigen' : 'Nur zukünftige anzeigen'),
],
),
),
)
)).toList(),
.toList(),
onSelected: (e) => bloc.add(SetPastHolidaysVisible(e)),
),
],
),
body: LoadableStateConsumer<HolidaysBloc, HolidaysState>(
onLoad: (state) {
if(state.showDisclaimer) showDisclaimer();
if (state.showDisclaimer) showDisclaimer();
bloc.add(DisclaimerDismissed());
},
child: (state, loading) => ListViewUtil.fromList<Holiday>(bloc.getHolidays(), (holiday) {
var holidayType = holiday.name.split(' ').first.capitalize();
String formatDate(String date) => Jiffy.parse(date).format(pattern: 'dd.MM.yyyy');
String getYear(String date, {String format = 'yyyy'}) => Jiffy.parse(date).format(pattern: format);
child: (state, loading) => ListViewUtil.fromList<Holiday>(
bloc.getHolidays(),
(holiday) {
var holidayType = holiday.name.split(' ').first.capitalize();
String formatDate(String date) =>
Jiffy.parse(date).format(pattern: 'dd.MM.yyyy');
String getYear(String date, {String format = 'yyyy'}) =>
Jiffy.parse(date).format(pattern: format);
String getHolidayYear(String startDate, String endDate) => getYear(startDate) == getYear(endDate)
String getHolidayYear(String startDate, String endDate) =>
getYear(startDate) == getYear(endDate)
? getYear(startDate)
: '${getYear(startDate)}/${getYear(endDate, format: 'yy')}';
return ListTile(
leading: const CenteredLeading(Icon(Icons.calendar_month)),
title: Text('$holidayType ${getHolidayYear(holiday.start, holiday.end)}'),
subtitle: Text('${formatDate(holiday.start)} - ${formatDate(holiday.end)}'),
onTap: () => showDetailsBottomSheet(
context,
header: Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
child: Text(
'$holidayType ${holiday.year} in Hessen',
style: Theme.of(context).textTheme.titleLarge,
),
return ListTile(
leading: const CenteredLeading(Icon(Icons.calendar_month)),
title: Text(
'$holidayType ${getHolidayYear(holiday.start, holiday.end)}',
),
children: (sheetCtx) => [
ListTile(
leading: const CenteredLeading(Icon(Icons.signpost_outlined)),
title: Text(holiday.name.capitalize()),
subtitle: Text(holiday.slug.capitalize()),
),
ListTile(
leading: const Icon(Icons.date_range_outlined),
title: Text('vom ${formatDate(holiday.start)}'),
),
ListTile(
leading: const Icon(Icons.date_range_outlined),
title: Text('bis zum ${formatDate(holiday.end)}'),
),
if (DateTime.parse(holiday.start).difference(DateTime.now()).isNegative)
ListTile(
leading: const CenteredLeading(Icon(Icons.content_paste_search_outlined)),
title: Text(Jiffy.parse(holiday.start).fromNow()),
)
else
ListTile(
leading: const CenteredLeading(Icon(Icons.timer_outlined)),
title: AnimatedTime(callback: () => DateTime.parse(holiday.start).difference(DateTime.now())),
subtitle: Text(Jiffy.parse(holiday.start).fromNow()),
subtitle: Text(
'${formatDate(holiday.start)} - ${formatDate(holiday.end)}',
),
onTap: () => showDetailsBottomSheet(
context,
header: Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
child: Text(
'$holidayType ${holiday.year} in Hessen',
style: Theme.of(context).textTheme.titleLarge,
),
DebugTile(sheetCtx).jsonData(holiday.toJson()),
],
),
trailing: const Icon(Icons.arrow_right),
);
}),
),
children: (sheetCtx) => [
ListTile(
leading: const CenteredLeading(
Icon(Icons.signpost_outlined),
),
title: Text(holiday.name.capitalize()),
subtitle: Text(holiday.slug.capitalize()),
),
ListTile(
leading: const Icon(Icons.date_range_outlined),
title: Text('vom ${formatDate(holiday.start)}'),
),
ListTile(
leading: const Icon(Icons.date_range_outlined),
title: Text('bis zum ${formatDate(holiday.end)}'),
),
if (DateTime.parse(
holiday.start,
).difference(DateTime.now()).isNegative)
ListTile(
leading: const CenteredLeading(
Icon(Icons.content_paste_search_outlined),
),
title: Text(Jiffy.parse(holiday.start).fromNow()),
)
else
ListTile(
leading: const CenteredLeading(
Icon(Icons.timer_outlined),
),
title: AnimatedTime(
callback: () => DateTime.parse(
holiday.start,
).difference(DateTime.now()),
),
subtitle: Text(Jiffy.parse(holiday.start).fromNow()),
),
DebugTile(sheetCtx).jsonData(holiday.toJson()),
],
),
trailing: const Icon(Icons.arrow_right),
);
},
),
),
);
},
@@ -19,7 +19,8 @@ class MarianumDatesView extends StatelessWidget {
static List<_MonthGroup> _groupByMonth(List<MarianumDate> events) {
final byMonth = <String, List<MarianumDate>>{};
for (final e in events) {
final key = '${e.start.year.toString().padLeft(4, '0')}-${e.start.month.toString().padLeft(2, '0')}';
final key =
'${e.start.year.toString().padLeft(4, '0')}-${e.start.month.toString().padLeft(2, '0')}';
byMonth.putIfAbsent(key, () => []).add(e);
}
final keys = byMonth.keys.toList()..sort();
@@ -31,7 +32,8 @@ class MarianumDatesView extends StatelessWidget {
}
@override
Widget build(BuildContext context) => BlocModule<MarianumDatesBloc, LoadableState<MarianumDatesState>>(
Widget build(BuildContext context) =>
BlocModule<MarianumDatesBloc, LoadableState<MarianumDatesState>>(
create: (context) => MarianumDatesBloc(),
autoRebuild: true,
child: (context, bloc, state) => Scaffold(
@@ -42,18 +44,26 @@ class MarianumDatesView extends StatelessWidget {
initialValue: bloc.showPastEvents(),
icon: const Icon(Icons.history),
itemBuilder: (context) => [true, false]
.map((e) => PopupMenuItem<bool>(
value: e,
enabled: e != bloc.showPastEvents(),
child: Row(
children: [
Icon(e ? Icons.history_outlined : Icons.history_toggle_off_outlined,
color: Theme.of(context).colorScheme.onSurface),
const SizedBox(width: 15),
Text(e ? 'Alle anzeigen' : 'Nur zukünftige anzeigen'),
],
),
))
.map(
(e) => PopupMenuItem<bool>(
value: e,
enabled: e != bloc.showPastEvents(),
child: Row(
children: [
Icon(
e
? Icons.history_outlined
: Icons.history_toggle_off_outlined,
color: Theme.of(context).colorScheme.onSurface,
),
const SizedBox(width: 15),
Text(
e ? 'Alle anzeigen' : 'Nur zukünftige anzeigen',
),
],
),
),
)
.toList(),
onSelected: (e) => bloc.add(SetPastEventsVisible(e)),
),
@@ -61,7 +71,10 @@ class MarianumDatesView extends StatelessWidget {
icon: const Icon(Icons.search),
onPressed: () {
final events = bloc.getEvents() ?? const <MarianumDate>[];
showSearch(context: context, delegate: SearchMarianumDates(events));
showSearch(
context: context,
delegate: SearchMarianumDates(events),
);
},
),
],
@@ -89,7 +102,8 @@ class MarianumDatesView extends StatelessWidget {
),
SliverList.builder(
itemCount: group.events.length,
itemBuilder: (_, i) => MarianumDateRow(event: group.events[i]),
itemBuilder: (_, i) =>
MarianumDateRow(event: group.events[i]),
),
],
),
@@ -21,15 +21,15 @@ class SearchMarianumDates extends SearchDelegate<MarianumDate?> {
@override
List<Widget>? buildActions(BuildContext context) => [
if (query.isNotEmpty)
IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)),
];
if (query.isNotEmpty)
IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)),
];
@override
Widget? buildLeading(BuildContext context) => IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => close(context, null),
);
icon: const Icon(Icons.arrow_back),
onPressed: () => close(context, null),
);
@override
Widget buildResults(BuildContext context) {
@@ -32,12 +32,16 @@ void showEventDetailsSheet(BuildContext context, MarianumDate event) {
if (isUpcoming)
ListTile(
leading: const CenteredLeading(Icon(Icons.timer_outlined)),
title: AnimatedTime(callback: () => event.start.difference(DateTime.now())),
title: AnimatedTime(
callback: () => event.start.difference(DateTime.now()),
),
subtitle: Text(event.start.formatRelative()),
)
else
ListTile(
leading: const CenteredLeading(Icon(Icons.content_paste_search_outlined)),
leading: const CenteredLeading(
Icon(Icons.content_paste_search_outlined),
),
title: Text(event.start.formatRelative()),
),
DebugTile(sheetContext).jsonData(event.toJson()),
@@ -63,9 +63,12 @@ class MarianumDateRow extends StatelessWidget {
event.title.isEmpty ? '(ohne Titel)' : event.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface,
),
),
if (event.description != null && event.description!.trim().isNotEmpty) ...[
if (event.description != null &&
event.description!.trim().isNotEmpty) ...[
const SizedBox(height: 2),
Text(
event.description!.trim(),
@@ -88,7 +91,9 @@ class MarianumDateRow extends StatelessWidget {
),
const SizedBox(width: 4),
IconButton(
icon: _CalendarPlusIcon(color: theme.colorScheme.onSurfaceVariant),
icon: _CalendarPlusIcon(
color: theme.colorScheme.onSurfaceVariant,
),
tooltip: 'In Stundenplan übernehmen',
onPressed: () => showDialog(
context: context,
@@ -117,25 +122,25 @@ class _CalendarPlusIcon extends StatelessWidget {
@override
Widget build(BuildContext context) => SizedBox(
width: 22,
height: 22,
child: Stack(
clipBehavior: Clip.none,
children: [
Icon(Icons.event_outlined, size: 22, color: color),
Positioned(
right: -2,
bottom: -2,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle,
),
padding: const EdgeInsets.all(1),
child: Icon(Icons.add_circle, size: 12, color: color),
),
width: 22,
height: 22,
child: Stack(
clipBehavior: Clip.none,
children: [
Icon(Icons.event_outlined, size: 22, color: color),
Positioned(
right: -2,
bottom: -2,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle,
),
],
padding: const EdgeInsets.all(1),
child: Icon(Icons.add_circle, size: 12, color: color),
),
),
);
],
),
);
}
@@ -7,7 +7,11 @@ class MonthHeaderDelegate extends SliverPersistentHeaderDelegate {
static const double _height = 38;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
final theme = Theme.of(context);
return Container(
height: _height,
@@ -32,5 +36,6 @@ class MonthHeaderDelegate extends SliverPersistentHeaderDelegate {
double get minExtent => _height;
@override
bool shouldRebuild(covariant MonthHeaderDelegate oldDelegate) => oldDelegate.label != label;
bool shouldRebuild(covariant MonthHeaderDelegate oldDelegate) =>
oldDelegate.label != label;
}
@@ -11,32 +11,36 @@ class MarianumMessageListView extends StatelessWidget {
const MarianumMessageListView({super.key});
@override
Widget build(BuildContext context) => BlocModule<MarianumMessageBloc, LoadableState<MarianumMessageState>>(
Widget build(
BuildContext context,
) => BlocModule<MarianumMessageBloc, LoadableState<MarianumMessageState>>(
create: (context) => MarianumMessageBloc(),
child: (context, bloc, state) => Scaffold(
appBar: AppBar(
title: const Text('Marianum Message'),
appBar: AppBar(title: const Text('Marianum Message')),
body: LoadableStateConsumer<MarianumMessageBloc, MarianumMessageState>(
child: (state, loading) => ListView.builder(
itemCount: state.messageList.messages.length,
itemBuilder: (context, index) {
var message = state.messageList.messages.toList()[index];
return ListTile(
leading: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [Icon(Icons.newspaper)],
),
title: Text(message.name, overflow: TextOverflow.ellipsis),
subtitle: Text('vom ${message.date}'),
trailing: const Icon(Icons.arrow_right),
onTap: () {
AppRoutes.openMarianumMessage(
context,
state.messageList.base,
message,
);
},
);
},
),
body: LoadableStateConsumer<MarianumMessageBloc, MarianumMessageState>(
child: (state, loading) => ListView.builder(
itemCount: state.messageList.messages.length,
itemBuilder: (context, index) {
var message = state.messageList.messages.toList()[index];
return ListTile(
leading: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [Icon(Icons.newspaper)],
),
title: Text(message.name, overflow: TextOverflow.ellipsis),
subtitle: Text('vom ${message.date}'),
trailing: const Icon(Icons.arrow_right),
onTap: () {
AppRoutes.openMarianumMessage(context, state.messageList.base, message);
},
);
}
),
),
)
),
),
);
}
@@ -16,34 +16,34 @@ class MessageView extends StatefulWidget {
}
class _MessageViewState extends State<MessageView> {
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: Text(widget.message.name),
),
body: SfPdfViewer.network(
widget.basePath + widget.message.url,
enableHyperlinkNavigation: true,
onDocumentLoadFailed: (PdfDocumentLoadFailedDetails e) {
Navigator.of(context).pop();
InfoDialog.show(
context,
"Dokument '${widget.message.name}' konnte nicht geladen werden:\n${e.description}",
title: 'Fehler beim öffnen',
);
},
onHyperlinkClicked: (PdfHyperlinkClickedDetails e) {
showDialog(
context: context,
builder: (context) => ConfirmDialog(
title: 'Link öffnen',
content: 'Möchtest du den folgenden Link öffnen?\n${e.uri}',
confirmButton: 'Öffnen',
onConfirm: () => launchUrl(Uri.parse(e.uri), mode: LaunchMode.externalApplication),
appBar: AppBar(title: Text(widget.message.name)),
body: SfPdfViewer.network(
widget.basePath + widget.message.url,
enableHyperlinkNavigation: true,
onDocumentLoadFailed: (PdfDocumentLoadFailedDetails e) {
Navigator.of(context).pop();
InfoDialog.show(
context,
"Dokument '${widget.message.name}' konnte nicht geladen werden:\n${e.description}",
title: 'Fehler beim öffnen',
);
},
onHyperlinkClicked: (PdfHyperlinkClickedDetails e) {
showDialog(
context: context,
builder: (context) => ConfirmDialog(
title: 'Link öffnen',
content: 'Möchtest du den folgenden Link öffnen?\n${e.uri}',
confirmButton: 'Öffnen',
onConfirm: () => launchUrl(
Uri.parse(e.uri),
mode: LaunchMode.externalApplication,
),
);
},
),
);
),
);
},
),
);
}
+117 -90
View File
@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
@@ -46,40 +45,49 @@ class _FeedbackDialogState extends State<FeedbackDialog> {
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('Feedback'),
),
body: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.max,
children: [
const SizedBox(height: 5),
const Text('Feedback, Anregungen, Ideen, Fehler und Verbesserungen', textAlign: TextAlign.center),
const SizedBox(height: 15),
const Text('Bitte gib keine geheimen Daten wie z.B. Passwörter weiter.', textAlign: TextAlign.center, style: TextStyle(fontSize: 11)),
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.all(10),
child: TextField(
onChanged: (value) {
if(value.trim().toLowerCase() == 'ranzig') {
_feedbackInput.text = 'selber';
}
},
controller: _feedbackInput,
autofocus: true,
decoration: InputDecoration(
border: const OutlineInputBorder(),
label: const Text('Feedback und Verbesserungen'),
errorText: _textFieldEmpty ? 'Bitte gib eine Beschreibung an!' : null,
),
minLines: 4,
maxLines: 7,
onTapOutside: (PointerDownEvent event) => FocusBehaviour.textFieldTapOutside(context),
appBar: AppBar(title: const Text('Feedback')),
body: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.max,
children: [
const SizedBox(height: 5),
const Text(
'Feedback, Anregungen, Ideen, Fehler und Verbesserungen',
textAlign: TextAlign.center,
),
const SizedBox(height: 15),
const Text(
'Bitte gib keine geheimen Daten wie z.B. Passwörter weiter.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 11),
),
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.all(10),
child: TextField(
onChanged: (value) {
if (value.trim().toLowerCase() == 'ranzig') {
_feedbackInput.text = 'selber';
}
},
controller: _feedbackInput,
autofocus: true,
decoration: InputDecoration(
border: const OutlineInputBorder(),
label: const Text('Feedback und Verbesserungen'),
errorText: _textFieldEmpty
? 'Bitte gib eine Beschreibung an!'
: null,
),
minLines: 4,
maxLines: 7,
onTapOutside: (PointerDownEvent event) =>
FocusBehaviour.textFieldTapOutside(context),
),
const SizedBox(height: 10),
if(_image != null) Row(
),
const SizedBox(height: 10),
if (_image != null)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
badges.Badge(
@@ -109,76 +117,95 @@ class _FeedbackDialogState extends State<FeedbackDialog> {
),
],
),
Padding(
padding: const EdgeInsets.all(5),
Padding(
padding: const EdgeInsets.all(5),
child: Visibility(
visible: _error != null,
child: Visibility(
visible: _error != null,
child: Visibility(
visible: context.read<SettingsCubit>().val().devToolsEnabled,
replacement: const Text('Senden fehlgeschlagen, bitte überprüfe die Internetverbindung.', textAlign: TextAlign.center, style: TextStyle(color: Colors.red)),
child: Text('Senden fehlgeschlagen: \n $_error', textAlign: TextAlign.center, style: const TextStyle(color: Colors.red)),
visible: context.read<SettingsCubit>().val().devToolsEnabled,
replacement: const Text(
'Senden fehlgeschlagen, bitte überprüfe die Internetverbindung.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.red),
),
child: Text(
'Senden fehlgeschlagen: \n $_error',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.red),
),
),
),
Padding(
padding: const EdgeInsets.only(right: 20, left: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Visibility(
visible: _image == null,
child: IconButton(
onPressed: () async {
context.loaderOverlay.show();
final picked = await FilePick.multipleGalleryPick();
final imageData = await picked?.first.readAsBytes();
if(context.mounted) context.loaderOverlay.hide();
setState(() {
_image = imageData;
});
},
icon: const Icon(Icons.attach_file_outlined),
),
),
const Expanded(child: SizedBox.shrink()),
TextButton(
onPressed: () async {
if(_feedbackInput.text.isEmpty){
setState(() {
_textFieldEmpty = true;
});
return;
}
context.loaderOverlay.show();
unawaited(AddFeedback(
AddFeedbackParams(
user: AccountData().getUserSecret(),
feedback: _feedbackInput.text,
screenshot: _image != null ? base64Encode(_image!) : null,
appVersion: int.parse((await PackageInfo.fromPlatform()).buildNumber),
),
Padding(
padding: const EdgeInsets.only(right: 20, left: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Visibility(
visible: _image == null,
child: IconButton(
onPressed: () async {
context.loaderOverlay.show();
final picked = await FilePick.multipleGalleryPick();
final imageData = await picked?.first.readAsBytes();
if (context.mounted) context.loaderOverlay.hide();
setState(() {
_image = imageData;
});
},
icon: const Icon(Icons.attach_file_outlined),
),
),
const Expanded(child: SizedBox.shrink()),
TextButton(
onPressed: () async {
if (_feedbackInput.text.isEmpty) {
setState(() {
_textFieldEmpty = true;
});
return;
}
context.loaderOverlay.show();
unawaited(
AddFeedback(
AddFeedbackParams(
user: AccountData().getUserSecret(),
feedback: _feedbackInput.text,
screenshot: _image != null
? base64Encode(_image!)
: null,
appVersion: int.parse(
(await PackageInfo.fromPlatform()).buildNumber,
),
).run().then((value) {
),
)
.run()
.then((value) {
if (!context.mounted) return;
Navigator.of(context).pop();
InfoDialog.show(context, 'Danke für dein Feedback!');
InfoDialog.show(
context,
'Danke für dein Feedback!',
);
context.loaderOverlay.hide();
}).catchError((Object error, StackTrace trace) {
})
.catchError((Object error, StackTrace trace) {
if (!mounted) return;
setState(() {
_error = error.toString();
});
if (!context.mounted) return;
context.loaderOverlay.hide();
}));
},
child: const Text('Senden'),
)
]
)
)
],
),
}),
);
},
child: const Text('Senden'),
),
],
),
),
],
),
);
),
);
}
+9 -9
View File
@@ -6,14 +6,14 @@ class Roomplan extends StatelessWidget {
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('Raumplan'),
appBar: AppBar(title: const Text('Raumplan')),
body: PhotoView(
imageProvider: Image.asset('assets/img/raumplan.png').image,
minScale: 0.5,
maxScale: 2.0,
backgroundDecoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
),
body: PhotoView(
imageProvider: Image.asset('assets/img/raumplan.png').image,
minScale: 0.5,
maxScale: 2.0,
backgroundDecoration: BoxDecoration(color: Theme.of(context).colorScheme.surface),
),
);
),
);
}
@@ -14,7 +14,10 @@ class AppSharePlatformView extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(title, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
Text(
title,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 30),
Container(
padding: const EdgeInsets.all(10),
@@ -26,8 +29,8 @@ class AppSharePlatformView extends StatelessWidget {
version: QrVersions.auto,
size: 200,
dataModuleStyle: QrDataModuleStyle(
color: foregroundColor,
dataModuleShape: QrDataModuleShape.square
color: foregroundColor,
dataModuleShape: QrDataModuleShape.square,
),
eyeStyle: QrEyeStyle(
color: foregroundColor,
+22 -16
View File
@@ -25,23 +25,29 @@ class _QrShareViewState extends State<QrShareView> {
@override
Widget build(BuildContext context) => DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: const Text('Teile die App'),
bottom: const TabBar(
tabs: [
Tab(icon: Icon(Icons.android_outlined), text: 'Android'),
Tab(icon: Icon(Icons.apple_outlined), text: 'iOS & iPadOS'),
],
),
),
body: const TabBarView(
children: [
AppSharePlatformView('Für Android', 'https://play.google.com/store/apps/details?id=eu.mhsl.marianum.mobile.client'),
AppSharePlatformView('Für iOS & iPad', 'https://apps.apple.com/us/app/marianum-fulda/id6458789560'),
length: 2,
child: Scaffold(
appBar: AppBar(
title: const Text('Teile die App'),
bottom: const TabBar(
tabs: [
Tab(icon: Icon(Icons.android_outlined), text: 'Android'),
Tab(icon: Icon(Icons.apple_outlined), text: 'iOS & iPadOS'),
],
),
),
);
body: const TabBarView(
children: [
AppSharePlatformView(
'Für Android',
'https://play.google.com/store/apps/details?id=eu.mhsl.marianum.mobile.client',
),
AppSharePlatformView(
'Für iOS & iPad',
'https://apps.apple.com/us/app/marianum-fulda/id6458789560',
),
],
),
),
);
}
@@ -30,14 +30,17 @@ Future<ShareTargetType?> showSelectShareTypeSheet(BuildContext context) {
trailing: const Icon(Icons.arrow_right),
onTap: () {
Navigator.of(sheetCtx).pop();
SharePlus.instance.share(ShareParams(
sharePositionOrigin: SharePositionOrigin.get(sheetCtx),
subject: 'App Teilen',
text: 'Hol dir die für das Marianum maßgeschneiderte App:'
'\n\nAndroid: https://play.google.com/store/apps/details?id=eu.mhsl.marianum.mobile.client '
'\niOS: https://apps.apple.com/us/app/marianum-fulda/id6458789560 '
'\n\nViel Spaß!',
));
SharePlus.instance.share(
ShareParams(
sharePositionOrigin: SharePositionOrigin.get(sheetCtx),
subject: 'App Teilen',
text:
'Hol dir die für das Marianum maßgeschneiderte App:'
'\n\nAndroid: https://play.google.com/store/apps/details?id=eu.mhsl.marianum.mobile.client '
'\niOS: https://apps.apple.com/us/app/marianum-fulda/id6458789560 '
'\n\nViel Spaß!',
),
);
},
),
],
+67 -54
View File
@@ -1,4 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart';
@@ -24,67 +23,81 @@ class _OverhangState extends State<Overhang> {
appBar: AppBar(
title: const Text('Mehr'),
actions: [
IconButton(onPressed: () => AppRoutes.openSettings(context), icon: const Icon(Icons.settings)),
IconButton(
onPressed: () => AppRoutes.openSettings(context),
icon: const Icon(Icons.settings),
),
],
),
body: _overhang(),
);
Widget _overhang() => ListView(
children: [
...AppModule.getOverhangModules(context).map((e) => e.toListTile(context)),
children: [
...AppModule.getOverhangModules(
context,
).map((e) => e.toListTile(context)),
const Divider(),
const Divider(),
ListTile(
leading: const Icon(Icons.share_outlined),
title: const Text('Teile die App'),
subtitle: const Text('Mit Freunden und deiner Klasse teilen'),
ListTile(
leading: const Icon(Icons.share_outlined),
title: const Text('Teile die App'),
subtitle: const Text('Mit Freunden und deiner Klasse teilen'),
trailing: const Icon(Icons.arrow_right),
onTap: () async {
final result = await showSelectShareTypeSheet(context);
if (!mounted || result != ShareTargetType.qr) return;
if (context.mounted) AppRoutes.openQrShare(context);
},
),
FutureBuilder(
future: InAppReview.instance.isAvailable(),
builder: (context, snapshot) {
if (!snapshot.hasData) return const SizedBox.shrink();
String? getPlatformStoreName() {
if (Platform.isAndroid) return 'Play store';
if (Platform.isIOS) return 'App store';
return null;
}
return ListTile(
leading: const CenteredLeading(Icon(Icons.star_rate_outlined)),
title: const Text('App bewerten'),
subtitle: getPlatformStoreName().wrapNullable(
(data) => Text('Im $data'),
),
trailing: const Icon(Icons.arrow_right),
onTap: () async {
final result = await showSelectShareTypeSheet(context);
if (!mounted || result != ShareTargetType.qr) return;
if (context.mounted) AppRoutes.openQrShare(context);
onTap: () {
InAppReview.instance
.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',
);
},
);
},
),
FutureBuilder(
future: InAppReview.instance.isAvailable(),
builder: (context, snapshot) {
if(!snapshot.hasData) return const SizedBox.shrink();
String? getPlatformStoreName() {
if(Platform.isAndroid) return 'Play store';
if(Platform.isIOS) return 'App store';
return null;
}
return ListTile(
leading: const CenteredLeading(Icon(Icons.star_rate_outlined)),
title: const Text('App bewerten'),
subtitle: getPlatformStoreName().wrapNullable((data) => Text('Im $data')),
trailing: const Icon(Icons.arrow_right),
onTap: () {
InAppReview.instance.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');
},
);
},
);
},
),
ListTile(
leading: const CenteredLeading(Icon(Icons.feedback_outlined)),
title: const Text('Du hast eine Idee?'),
subtitle: const Text('Fehler und Verbessungsvorschläge'),
trailing: const Icon(Icons.arrow_right),
onTap: () => AppRoutes.openFeedback(context),
),
],
);
);
},
),
ListTile(
leading: const CenteredLeading(Icon(Icons.feedback_outlined)),
title: const Text('Du hast eine Idee?'),
subtitle: const Text('Fehler und Verbessungsvorschläge'),
trailing: const Icon(Icons.arrow_right),
onTap: () => AppRoutes.openFeedback(context),
),
],
);
}
@@ -17,53 +17,51 @@ import '../../files/data/sort_options.dart';
class DefaultSettings {
static Settings get() => Settings(
appTheme: ThemeMode.system,
devToolsEnabled: false,
modulesSettings: ModulesSettings(
moduleOrder: [
Modules.timetable,
Modules.talk,
Modules.files,
Modules.marianumMessage,
Modules.roomPlan,
Modules.gradeAveragesCalculator,
Modules.holidays,
Modules.marianumDates,
],
hiddenModules: [],
autoFillBottomBar: true,
fixedBottomBarSlots: 3,
),
timetableSettings: TimetableSettings(
connectDoubleLessons: true,
timetableNameMode: TimetableNameMode.name,
),
talkSettings: TalkSettings(
sortFavoritesToTop: true,
sortUnreadToTop: false,
drafts: {},
draftReplies: {},
),
fileSettings: FileSettings(
sortFoldersToTop: true,
ascending: true,
sortBy: SortOption.name
),
holidaysSettings: HolidaysSettings(
dismissedDisclaimer: false,
showPastEvents: false,
),
fileViewSettings: FileViewSettings(
alwaysOpenExternally: Platform.isIOS,
),
notificationSettings: NotificationSettings(
askUsageDismissed: false,
enabled: false,
),
devToolsSettings: DevToolsSettings(
checkerboardOffscreenLayers: false,
checkerboardRasterCacheImages: false,
showPerformanceOverlay: false,
),
);
appTheme: ThemeMode.system,
devToolsEnabled: false,
modulesSettings: ModulesSettings(
moduleOrder: [
Modules.timetable,
Modules.talk,
Modules.files,
Modules.marianumMessage,
Modules.roomPlan,
Modules.gradeAveragesCalculator,
Modules.holidays,
Modules.marianumDates,
],
hiddenModules: [],
autoFillBottomBar: true,
fixedBottomBarSlots: 3,
),
timetableSettings: TimetableSettings(
connectDoubleLessons: true,
timetableNameMode: TimetableNameMode.name,
),
talkSettings: TalkSettings(
sortFavoritesToTop: true,
sortUnreadToTop: false,
drafts: {},
draftReplies: {},
),
fileSettings: FileSettings(
sortFoldersToTop: true,
ascending: true,
sortBy: SortOption.name,
),
holidaysSettings: HolidaysSettings(
dismissedDisclaimer: false,
showPastEvents: false,
),
fileViewSettings: FileViewSettings(alwaysOpenExternally: Platform.isIOS),
notificationSettings: NotificationSettings(
askUsageDismissed: false,
enabled: false,
),
devToolsSettings: DevToolsSettings(
checkerboardOffscreenLayers: false,
checkerboardRasterCacheImages: false,
showPerformanceOverlay: false,
),
);
}
@@ -14,103 +14,144 @@ class ModuleSortBody extends StatelessWidget {
const ModuleSortBody({super.key});
@override
Widget build(BuildContext context) => BlocBuilder<SettingsCubit, model.Settings>(builder: (context, _) {
final settings = context.read<SettingsCubit>();
final modulesSettings = settings.val().modulesSettings;
Widget build(
BuildContext context,
) => BlocBuilder<SettingsCubit, model.Settings>(
builder: (context, _) {
final settings = context.read<SettingsCubit>();
final modulesSettings = settings.val().modulesSettings;
void changeVisibility(Modules module) {
var hidden = settings.val(write: true).modulesSettings.hiddenModules;
if (hidden.contains(module)) {
hidden.remove(module);
} else if (hidden.length < 3) {
hidden.add(module);
void changeVisibility(Modules module) {
var hidden = settings.val(write: true).modulesSettings.hiddenModules;
if (hidden.contains(module)) {
hidden.remove(module);
} else if (hidden.length < 3) {
hidden.add(module);
}
}
}
return ReorderableListView(
header: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Text(
'Halte und ziehe einen Eintrag, um ihn zu verschieben.\nEs können 3 Bereiche ausgeblendet werden.',
textAlign: TextAlign.center,
),
),
SwitchListTile(
title: const Text('Modulleiste automatisch füllen'),
subtitle: const Text('Auf größeren Bildschirmen werden mehr Module direkt angezeigt'),
value: modulesSettings.autoFillBottomBar,
onChanged: (value) => settings.val(write: true).modulesSettings.autoFillBottomBar = value,
),
if (!modulesSettings.autoFillBottomBar)
ListTile(
title: const Text('Anzahl Slots in der Modulleiste'),
subtitle: Text('${modulesSettings.fixedBottomBarSlots} Module (zzgl. „Mehr")'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: modulesSettings.fixedBottomBarSlots > AppModule.minBottomBarSlots
? () => settings.val(write: true).modulesSettings.fixedBottomBarSlots -= 1
: null,
),
Text('${modulesSettings.fixedBottomBarSlots}'),
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: modulesSettings.fixedBottomBarSlots < AppModule.maxBottomBarSlots
? () => settings.val(write: true).modulesSettings.fixedBottomBarSlots += 1
: null,
),
],
return ReorderableListView(
header: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Text(
'Halte und ziehe einen Eintrag, um ihn zu verschieben.\nEs können 3 Bereiche ausgeblendet werden.',
textAlign: TextAlign.center,
),
),
const Divider(),
],
),
children: AppModule.modules(context, showFiltered: true)
.map((key, value) => MapEntry(key, value.toListTile(
context,
key: Key(key.name),
isReorder: true,
onVisibleChange: () => changeVisibility(key),
isVisible: !settings.val().modulesSettings.hiddenModules.contains(key),
)))
.values
.toList(),
onReorder: (oldIndex, newIndex) {
if (newIndex > oldIndex) newIndex -= 1;
SwitchListTile(
title: const Text('Modulleiste automatisch füllen'),
subtitle: const Text(
'Auf größeren Bildschirmen werden mehr Module direkt angezeigt',
),
value: modulesSettings.autoFillBottomBar,
onChanged: (value) =>
settings.val(write: true).modulesSettings.autoFillBottomBar =
value,
),
if (!modulesSettings.autoFillBottomBar)
ListTile(
title: const Text('Anzahl Slots in der Modulleiste'),
subtitle: Text(
'${modulesSettings.fixedBottomBarSlots} Module (zzgl. „Mehr")',
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed:
modulesSettings.fixedBottomBarSlots >
AppModule.minBottomBarSlots
? () =>
settings
.val(write: true)
.modulesSettings
.fixedBottomBarSlots -=
1
: null,
),
Text('${modulesSettings.fixedBottomBarSlots}'),
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed:
modulesSettings.fixedBottomBarSlots <
AppModule.maxBottomBarSlots
? () =>
settings
.val(write: true)
.modulesSettings
.fixedBottomBarSlots +=
1
: null,
),
],
),
),
const Divider(),
],
),
children: AppModule.modules(context, showFiltered: true)
.map(
(key, value) => MapEntry(
key,
value.toListTile(
context,
key: Key(key.name),
isReorder: true,
onVisibleChange: () => changeVisibility(key),
isVisible: !settings
.val()
.modulesSettings
.hiddenModules
.contains(key),
),
),
)
.values
.toList(),
onReorder: (oldIndex, newIndex) {
if (newIndex > oldIndex) newIndex -= 1;
var order = settings.val().modulesSettings.moduleOrder.toList();
final movedModule = order.removeAt(oldIndex);
order.insert(newIndex, movedModule);
settings.val(write: true).modulesSettings.moduleOrder = order;
},
);
});
var order = settings.val().modulesSettings.moduleOrder.toList();
final movedModule = order.removeAt(oldIndex);
order.insert(newIndex, movedModule);
settings.val(write: true).modulesSettings.moduleOrder = order;
},
);
},
);
}
class ModulesSettingsPage extends StatelessWidget {
const ModulesSettingsPage({super.key});
@override
Widget build(BuildContext context) => BlocBuilder<SettingsCubit, model.Settings>(builder: (context, _) {
final settings = context.read<SettingsCubit>();
final isModified = settings.val().modulesSettings.toJson().toString() != DefaultSettings.get().modulesSettings.toJson().toString();
return Scaffold(
appBar: AppBar(
title: const Text('Module'),
actions: [
IconButton(
tooltip: 'Auf Standard zurücksetzen',
onPressed: isModified ? () => settings.val(write: true).modulesSettings = DefaultSettings.get().modulesSettings : null,
icon: const Icon(Icons.undo_outlined),
),
],
),
body: const ModuleSortBody(),
);
});
Widget build(BuildContext context) =>
BlocBuilder<SettingsCubit, model.Settings>(
builder: (context, _) {
final settings = context.read<SettingsCubit>();
final isModified =
settings.val().modulesSettings.toJson().toString() !=
DefaultSettings.get().modulesSettings.toJson().toString();
return Scaffold(
appBar: AppBar(
title: const Text('Module'),
actions: [
IconButton(
tooltip: 'Auf Standard zurücksetzen',
onPressed: isModified
? () => settings.val(write: true).modulesSettings =
DefaultSettings.get().modulesSettings
: null,
icon: const Icon(Icons.undo_outlined),
),
],
),
body: const ModuleSortBody(),
);
},
);
}
@@ -37,14 +37,18 @@ class AboutSection extends StatelessWidget {
leading: const CenteredLeading(Icon(Icons.code)),
title: const Text('Quellcode MarianumMobile/Client'),
subtitle: const Text('GNU GPL v3'),
onTap: () => ConfirmDialog.openBrowser(context, 'https://mhsl.eu/gitea/MarianumMobile/Client'),
onTap: () => ConfirmDialog.openBrowser(
context,
'https://mhsl.eu/gitea/MarianumMobile/Client',
),
),
ListTile(
leading: const Icon(Icons.developer_mode_outlined),
title: const Text('Entwicklermodus'),
trailing: Checkbox(
value: settings.val().devToolsEnabled,
onChanged: (state) => _toggleDeveloperMode(context, settings, state),
onChanged: (state) =>
_toggleDeveloperMode(context, settings, state),
),
),
Visibility(
@@ -62,8 +66,10 @@ class AboutSection extends StatelessWidget {
context: context,
applicationIcon: const Icon(Icons.apps),
applicationName: 'MarianumMobile',
applicationVersion: '${appInfo.appName}\n\nPackage: ${appInfo.packageName}\nVersion: ${appInfo.version}\nBuild: ${appInfo.buildNumber}',
applicationLegalese: 'Dies ist ein Inoffizieller Nextcloud & Webuntis Client und wird nicht vom Marianum selbst betrieben.\n'
applicationVersion:
'${appInfo.appName}\n\nPackage: ${appInfo.packageName}\nVersion: ${appInfo.version}\nBuild: ${appInfo.buildNumber}',
applicationLegalese:
'Dies ist ein Inoffizieller Nextcloud & Webuntis Client und wird nicht vom Marianum selbst betrieben.\n'
'Keinerlei Gewähr für Vollständigkeit, Richtigkeit und Aktualität!\n\n'
"${kReleaseMode ? "Production" : "Development"} build\n"
'Marianum Fulda 2023-${Jiffy.now().year}\nElias Müller',
@@ -71,49 +77,58 @@ class AboutSection extends StatelessWidget {
}
void _showPrivacyDialog(BuildContext context) => showDetailsBottomSheet(
context,
children: (sheetCtx) => [
ListTile(
leading: const CenteredLeading(Icon(Icons.school_outlined)),
title: const Text('Infos zum Marianum Fulda'),
subtitle: const Text('Für Talk-Chats und Dateien'),
trailing: const Icon(Icons.arrow_right),
onTap: () => PrivacyInfo(
providerText: 'Marianum',
imprintUrl: 'https://www.marianum-fulda.de/impressum',
privacyUrl: 'https://www.marianum-fulda.de/datenschutz',
).showPopup(sheetCtx),
),
ListTile(
leading: const CenteredLeading(Icon(Icons.date_range_outlined)),
title: const Text('Infos zu Web-/ Untis'),
subtitle: const Text('Für den Stundenplan'),
trailing: const Icon(Icons.arrow_right),
onTap: () => PrivacyInfo(
providerText: 'Untis',
imprintUrl: 'https://www.untis.at/impressum',
privacyUrl: 'https://www.untis.at/datenschutz-wu-apps',
).showPopup(sheetCtx),
),
ListTile(
leading: const CenteredLeading(Icon(Icons.send_time_extension_outlined)),
title: const Text('Infos zu mhsl'),
subtitle: const Text('Für Countdowns, Marianum Message und mehr'),
trailing: const Icon(Icons.arrow_right),
onTap: () => PrivacyInfo(
providerText: 'mhsl',
imprintUrl: 'https://mhsl.eu/id.html',
privacyUrl: 'https://mhsl.eu/datenschutz.html',
).showPopup(sheetCtx),
),
],
);
context,
children: (sheetCtx) => [
ListTile(
leading: const CenteredLeading(Icon(Icons.school_outlined)),
title: const Text('Infos zum Marianum Fulda'),
subtitle: const Text('Für Talk-Chats und Dateien'),
trailing: const Icon(Icons.arrow_right),
onTap: () => PrivacyInfo(
providerText: 'Marianum',
imprintUrl: 'https://www.marianum-fulda.de/impressum',
privacyUrl: 'https://www.marianum-fulda.de/datenschutz',
).showPopup(sheetCtx),
),
ListTile(
leading: const CenteredLeading(Icon(Icons.date_range_outlined)),
title: const Text('Infos zu Web-/ Untis'),
subtitle: const Text('Für den Stundenplan'),
trailing: const Icon(Icons.arrow_right),
onTap: () => PrivacyInfo(
providerText: 'Untis',
imprintUrl: 'https://www.untis.at/impressum',
privacyUrl: 'https://www.untis.at/datenschutz-wu-apps',
).showPopup(sheetCtx),
),
ListTile(
leading: const CenteredLeading(
Icon(Icons.send_time_extension_outlined),
),
title: const Text('Infos zu mhsl'),
subtitle: const Text('Für Countdowns, Marianum Message und mehr'),
trailing: const Icon(Icons.arrow_right),
onTap: () => PrivacyInfo(
providerText: 'mhsl',
imprintUrl: 'https://mhsl.eu/id.html',
privacyUrl: 'https://mhsl.eu/datenschutz.html',
).showPopup(sheetCtx),
),
],
);
void _toggleDeveloperMode(BuildContext context, SettingsCubit settings, bool? state) {
void _toggleDeveloperMode(
BuildContext context,
SettingsCubit settings,
bool? state,
) {
void apply() {
final enabled = state ?? false;
settings.val(write: true).devToolsEnabled = enabled;
if (!enabled) settings.val(write: true).devToolsSettings = DefaultSettings.get().devToolsSettings;
if (!enabled) {
settings.val(write: true).devToolsSettings =
DefaultSettings.get().devToolsSettings;
}
}
if (!state!) {
@@ -123,7 +138,8 @@ class AboutSection extends StatelessWidget {
ConfirmDialog(
title: 'Entwicklermodus',
content: 'Die Entwickleransicht bietet erweiterte Funktionen, die für den üblichen Gebrauch nicht benötigt werden.\n\n'
content:
'Die Entwickleransicht bietet erweiterte Funktionen, die für den üblichen Gebrauch nicht benötigt werden.\n\n'
'Die Verwendung der Tools kann darüber hinaus bei falscher Verwendung zu Fehlern führen.\n\n'
'Aktivieren auf eigene Verantwortung.',
confirmButton: 'Ja, ich verstehe das Risiko',
@@ -12,11 +12,11 @@ class AccountSection extends StatelessWidget {
@override
Widget build(BuildContext context) => ListTile(
leading: const CenteredLeading(Icon(Icons.logout_outlined)),
title: const Text('Konto abmelden'),
subtitle: Text('Angemeldet als ${AccountData().getUsername()}'),
onTap: () => _showLogoutDialog(context),
);
leading: const CenteredLeading(Icon(Icons.logout_outlined)),
title: const Text('Konto abmelden'),
subtitle: Text('Angemeldet als ${AccountData().getUsername()}'),
onTap: () => _showLogoutDialog(context),
);
Future<void> _showLogoutDialog(BuildContext context) async {
// Sequential logout flow: dialog wipes secure storage, dialog closes
@@ -17,17 +17,19 @@ class AppearanceSection extends StatelessWidget {
value: settings.val().appTheme,
icon: const Icon(Icons.arrow_drop_down),
items: ThemeMode.values
.map((e) => DropdownMenuItem<ThemeMode>(
value: e,
enabled: e != settings.val().appTheme,
child: Row(
children: [
Icon(AppTheme.getDisplayOptions(e).icon),
const SizedBox(width: 10),
Text(AppTheme.getDisplayOptions(e).displayName),
],
),
))
.map(
(e) => DropdownMenuItem<ThemeMode>(
value: e,
enabled: e != settings.val().appTheme,
child: Row(
children: [
Icon(AppTheme.getDisplayOptions(e).icon),
const SizedBox(width: 10),
Text(AppTheme.getDisplayOptions(e).displayName),
],
),
),
)
.toList(),
onChanged: (e) => settings.val(write: true).appTheme = e!,
),
@@ -1,4 +1,3 @@
import 'package:filesize/filesize.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -24,117 +23,150 @@ class DevToolsSection extends StatefulWidget {
class _DevToolsSectionState extends State<DevToolsSection> {
@override
Widget build(BuildContext context) => Column(
children: [
ListTile(
leading: const CenteredLeading(Icon(Icons.speed_outlined)),
title: const Text('Performance overlays'),
trailing: const Icon(Icons.arrow_right),
onTap: () {
showDetailsBottomSheet(
context,
children: (sheetCtx) => [
BlocBuilder<SettingsCubit, model.Settings>(
bloc: widget.settings,
builder: (_, _) {
final dev = widget.settings.val().devToolsSettings;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.auto_graph_outlined),
title: const Text('Performance graph'),
trailing: Checkbox(
value: dev.showPerformanceOverlay,
onChanged: (e) => widget.settings.val(write: true).devToolsSettings.showPerformanceOverlay = e!,
),
children: [
ListTile(
leading: const CenteredLeading(Icon(Icons.speed_outlined)),
title: const Text('Performance overlays'),
trailing: const Icon(Icons.arrow_right),
onTap: () {
showDetailsBottomSheet(
context,
children: (sheetCtx) => [
BlocBuilder<SettingsCubit, model.Settings>(
bloc: widget.settings,
builder: (_, _) {
final dev = widget.settings.val().devToolsSettings;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.auto_graph_outlined),
title: const Text('Performance graph'),
trailing: Checkbox(
value: dev.showPerformanceOverlay,
onChanged: (e) =>
widget.settings
.val(write: true)
.devToolsSettings
.showPerformanceOverlay =
e!,
),
ListTile(
leading: const Icon(Icons.screen_search_desktop_outlined),
title: const Text('Indicate offscreen layers'),
trailing: Checkbox(
value: dev.checkerboardOffscreenLayers,
onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardOffscreenLayers = e!,
),
),
ListTile(
leading: const Icon(
Icons.screen_search_desktop_outlined,
),
ListTile(
leading: const Icon(Icons.imagesearch_roller_outlined),
title: const Text('Indicate raster cache images'),
trailing: Checkbox(
value: dev.checkerboardRasterCacheImages,
onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardRasterCacheImages = e!,
),
title: const Text('Indicate offscreen layers'),
trailing: Checkbox(
value: dev.checkerboardOffscreenLayers,
onChanged: (e) =>
widget.settings
.val(write: true)
.devToolsSettings
.checkerboardOffscreenLayers =
e!,
),
],
);
},
),
],
);
},
),
ListTile(
leading: const Icon(Icons.imagesearch_roller_outlined),
title: const Text('Indicate raster cache images'),
trailing: Checkbox(
value: dev.checkerboardRasterCacheImages,
onChanged: (e) =>
widget.settings
.val(write: true)
.devToolsSettings
.checkerboardRasterCacheImages =
e!,
),
),
],
);
},
),
],
);
},
),
ListTile(
leading: const CenteredLeading(Icon(Icons.image_outlined)),
title: const Text('Thumb-storage'),
subtitle: Text(
'etwa ${filesize(PaintingBinding.instance.imageCache.currentSizeBytes)}\nLange tippen um zu löschen',
),
ListTile(
leading: const CenteredLeading(Icon(Icons.image_outlined)),
title: const Text('Thumb-storage'),
subtitle: Text('etwa ${filesize(PaintingBinding.instance.imageCache.currentSizeBytes)}\nLange tippen um zu löschen'),
onLongPress: () {
ConfirmDialog(
title: 'Thumbs cache löschen',
content: 'Alle zwischengespeicherten Bilder werden gelöscht.',
confirmButton: 'Unwiederruflich löschen',
onConfirm: () => PaintingBinding.instance.imageCache.clear(),
).asDialog(context);
},
onLongPress: () {
ConfirmDialog(
title: 'Thumbs cache löschen',
content: 'Alle zwischengespeicherten Bilder werden gelöscht.',
confirmButton: 'Unwiederruflich löschen',
onConfirm: () => PaintingBinding.instance.imageCache.clear(),
).asDialog(context);
},
),
ListTile(
leading: const CenteredLeading(
Icon(Icons.settings_applications_outlined),
),
ListTile(
leading: const CenteredLeading(Icon(Icons.settings_applications_outlined)),
title: const Text('Settings-storage JSON dump'),
subtitle: Text('etwa ${filesize(widget.settings.val().toJson().toString().length * 8)}\nLange tippen um zu löschen'),
onTap: () {
JsonViewer.asDialog(context, widget.settings.val().toJson());
},
onLongPress: () {
ConfirmDialog(
title: 'Einstellungen löschen',
content: 'Alle Einstellungen gehen verloren! Accountdaten sowie App-Daten sind nicht betroffen.',
confirmButton: 'Unwiederruflich Löschen',
onConfirm: () {
context.read<SettingsCubit>().reset();
},
).asDialog(context);
},
trailing: const Icon(Icons.arrow_right),
title: const Text('Settings-storage JSON dump'),
subtitle: Text(
'etwa ${filesize(widget.settings.val().toJson().toString().length * 8)}\nLange tippen um zu löschen',
),
ListTile(
leading: const CenteredLeading(Icon(Icons.data_object)),
title: const Text('Cache-storage JSON dump'),
subtitle: FutureBuilder(
future: const CacheView().totalSize(),
builder: (context, snapshot) => Text("etwa ${snapshot.hasError ? "?" : snapshot.hasData ? filesize(snapshot.data) : "..."}\nLange tippen um zu löschen"),
onTap: () {
JsonViewer.asDialog(context, widget.settings.val().toJson());
},
onLongPress: () {
ConfirmDialog(
title: 'Einstellungen löschen',
content:
'Alle Einstellungen gehen verloren! Accountdaten sowie App-Daten sind nicht betroffen.',
confirmButton: 'Unwiederruflich Löschen',
onConfirm: () {
context.read<SettingsCubit>().reset();
},
).asDialog(context);
},
trailing: const Icon(Icons.arrow_right),
),
ListTile(
leading: const CenteredLeading(Icon(Icons.data_object)),
title: const Text('Cache-storage JSON dump'),
subtitle: FutureBuilder(
future: const CacheView().totalSize(),
builder: (context, snapshot) => Text(
"etwa ${snapshot.hasError
? "?"
: snapshot.hasData
? filesize(snapshot.data)
: "..."}\nLange tippen um zu löschen",
),
onTap: () => AppRoutes.openCacheView(context),
onLongPress: () {
ConfirmDialog(
title: 'App-Cache löschen',
content: 'Alle cache Einträge werden gelöscht. Der Cache wird bei Nutzung der App automatisch erneut aufgebaut',
confirmButton: 'Unwiederruflich löschen',
onConfirm: () => const CacheView().clear().then((value) => setState((){})),
).asDialog(context);
},
trailing: const Icon(Icons.arrow_right),
),
ListTile(
leading: const CenteredLeading(Icon(Icons.data_object)),
title: const Text('BLOC-storage state cache'),
subtitle: const Text('Lange tippen um zu löschen'),
onLongPress: () {
ConfirmDialog(
title: 'BLOC-Cache löschen',
content: 'Alle cache Einträge werden gelöscht. Der Cache wird bei Nutzung der App automatisch erneut aufgebaut',
confirmButton: 'Unwiederruflich löschen',
onConfirm: () => HydratedBloc.storage.clear(),
).asDialog(context);
},
),
],
);
onTap: () => AppRoutes.openCacheView(context),
onLongPress: () {
ConfirmDialog(
title: 'App-Cache löschen',
content:
'Alle cache Einträge werden gelöscht. Der Cache wird bei Nutzung der App automatisch erneut aufgebaut',
confirmButton: 'Unwiederruflich löschen',
onConfirm: () =>
const CacheView().clear().then((value) => setState(() {})),
).asDialog(context);
},
trailing: const Icon(Icons.arrow_right),
),
ListTile(
leading: const CenteredLeading(Icon(Icons.data_object)),
title: const Text('BLOC-storage state cache'),
subtitle: const Text('Lange tippen um zu löschen'),
onLongPress: () {
ConfirmDialog(
title: 'BLOC-Cache löschen',
content:
'Alle cache Einträge werden gelöscht. Der Cache wird bei Nutzung der App automatisch erneut aufgebaut',
confirmButton: 'Unwiederruflich löschen',
onConfirm: () => HydratedBloc.storage.clear(),
).asDialog(context);
},
),
],
);
}
@@ -16,7 +16,8 @@ class FilesSection extends StatelessWidget {
title: const Text('Ordner in Dateien nach oben sortieren'),
trailing: Checkbox(
value: settings.val().fileSettings.sortFoldersToTop,
onChanged: (e) => settings.val(write: true).fileSettings.sortFoldersToTop = e!,
onChanged: (e) =>
settings.val(write: true).fileSettings.sortFoldersToTop = e!,
),
),
ListTile(
@@ -24,7 +25,12 @@ class FilesSection extends StatelessWidget {
title: const Text('Dateien immer mit Systemdialog öffnen'),
trailing: Checkbox(
value: settings.val().fileViewSettings.alwaysOpenExternally,
onChanged: (e) => settings.val(write: true).fileViewSettings.alwaysOpenExternally = e!,
onChanged: (e) =>
settings
.val(write: true)
.fileViewSettings
.alwaysOpenExternally =
e!,
),
),
],
@@ -7,10 +7,10 @@ class ModulesSection extends StatelessWidget {
@override
Widget build(BuildContext context) => ListTile(
leading: const Icon(Icons.apps_outlined),
title: const Text('Module'),
subtitle: const Text('Reihenfolge, Sichtbarkeit und Modulleiste anpassen'),
trailing: const Icon(Icons.arrow_right),
onTap: () => AppRoutes.openModulesSettings(context),
);
leading: const Icon(Icons.apps_outlined),
title: const Text('Module'),
subtitle: const Text('Reihenfolge, Sichtbarkeit und Modulleiste anpassen'),
trailing: const Icon(Icons.arrow_right),
onTap: () => AppRoutes.openModulesSettings(context),
);
}
@@ -21,7 +21,8 @@ class TalkSection extends StatelessWidget {
title: const Text('Favoriten im Talk nach oben sortieren'),
trailing: Checkbox(
value: talkSettings.sortFavoritesToTop,
onChanged: (e) => settings.val(write: true).talkSettings.sortFavoritesToTop = e!,
onChanged: (e) =>
settings.val(write: true).talkSettings.sortFavoritesToTop = e!,
),
),
ListTile(
@@ -29,11 +30,14 @@ class TalkSection extends StatelessWidget {
title: const Text('Ungelesene Chats nach oben sortieren'),
trailing: Checkbox(
value: talkSettings.sortUnreadToTop,
onChanged: (e) => settings.val(write: true).talkSettings.sortUnreadToTop = e!,
onChanged: (e) =>
settings.val(write: true).talkSettings.sortUnreadToTop = e!,
),
),
ListTile(
leading: const CenteredLeading(Icon(Icons.notifications_active_outlined)),
leading: const CenteredLeading(
Icon(Icons.notifications_active_outlined),
),
title: const Text('Push-Benachrichtigungen aktivieren'),
subtitle: const Text('Lange tippen für mehr Informationen'),
trailing: Checkbox(
@@ -53,12 +57,12 @@ class TalkSection extends StatelessWidget {
}
void _showInfoDialog(BuildContext context) => InfoDialog.show(
context,
"Aufgrund technischer Limitationen müssen Push-Nachrichten über einen externen Server - hier 'mhsl.eu' (Author dieser App) - erfolgen.\n\n"
'Wenn Push aktiviert wird, werden deine Zugangsdaten und ein Token verschlüsselt an den Betreiber gesendet und von ihm unverschlüsselt gespeichert.\n\n'
'Der extene Server verwendet die Zugangsdaten um sich maschinell in Nextcloud Talk anzumelden und via Websockets auf neue Nachrichten zu warten.\n\n'
'Wenn eine neue Nachricht eintrifft wird dein Telefon via FBC-Messaging (Google Firebase Push) vom externen Server benachrichtigt.\n\n'
'Behalte im Hinterkopf, dass deine Zugangsdaten auf einem externen Server gespeichert werden und dies trotz bester Absichten ein Sicherheitsrisiko sein kann!',
title: 'Info über Push',
);
context,
"Aufgrund technischer Limitationen müssen Push-Nachrichten über einen externen Server - hier 'mhsl.eu' (Author dieser App) - erfolgen.\n\n"
'Wenn Push aktiviert wird, werden deine Zugangsdaten und ein Token verschlüsselt an den Betreiber gesendet und von ihm unverschlüsselt gespeichert.\n\n'
'Der extene Server verwendet die Zugangsdaten um sich maschinell in Nextcloud Talk anzumelden und via Websockets auf neue Nachrichten zu warten.\n\n'
'Wenn eine neue Nachricht eintrifft wird dein Telefon via FBC-Messaging (Google Firebase Push) vom externen Server benachrichtigt.\n\n'
'Behalte im Hinterkopf, dass deine Zugangsdaten auf einem externen Server gespeichert werden und dies trotz bester Absichten ein Sicherheitsrisiko sein kann!',
title: 'Info über Push',
);
}
@@ -20,20 +20,25 @@ class TimetableSection extends StatelessWidget {
value: timetableSettings.timetableNameMode,
icon: const Icon(Icons.arrow_drop_down),
items: TimetableNameMode.values
.map((e) => DropdownMenuItem(
value: e,
enabled: e != timetableSettings.timetableNameMode,
child: Row(
children: [
Icon(TimetableNameModes.getDisplayOptions(e).icon),
const SizedBox(width: 10),
Text(TimetableNameModes.getDisplayOptions(e).displayName),
],
),
))
.map(
(e) => DropdownMenuItem(
value: e,
enabled: e != timetableSettings.timetableNameMode,
child: Row(
children: [
Icon(TimetableNameModes.getDisplayOptions(e).icon),
const SizedBox(width: 10),
Text(
TimetableNameModes.getDisplayOptions(e).displayName,
),
],
),
),
)
.toList(),
onChanged: (value) =>
settings.val(write: true).timetableSettings.timetableNameMode = value!,
settings.val(write: true).timetableSettings.timetableNameMode =
value!,
),
),
ListTile(
@@ -42,7 +47,11 @@ class TimetableSection extends StatelessWidget {
trailing: Checkbox(
value: timetableSettings.connectDoubleLessons,
onChanged: (e) =>
settings.val(write: true).timetableSettings.connectDoubleLessons = e!,
settings
.val(write: true)
.timetableSettings
.connectDoubleLessons =
e!,
),
),
],
+19 -19
View File
@@ -13,23 +13,23 @@ class Settings extends StatelessWidget {
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Einstellungen')),
body: ListView(
children: const [
AccountSection(),
Divider(),
AppearanceSection(),
Divider(),
ModulesSection(),
Divider(),
TimetableSection(),
Divider(),
TalkSection(),
Divider(),
FilesSection(),
Divider(),
AboutSection(),
],
),
);
appBar: AppBar(title: const Text('Einstellungen')),
body: ListView(
children: const [
AccountSection(),
Divider(),
AppearanceSection(),
Divider(),
ModulesSection(),
Divider(),
TimetableSection(),
Divider(),
TalkSection(),
Divider(),
FilesSection(),
Divider(),
AboutSection(),
],
),
);
}
@@ -9,7 +9,11 @@ class PrivacyInfo {
String privacyUrl;
String imprintUrl;
PrivacyInfo({required this.providerText, required this.imprintUrl, required this.privacyUrl});
PrivacyInfo({
required this.providerText,
required this.imprintUrl,
required this.privacyUrl,
});
void showPopup(BuildContext context) {
showDetailsBottomSheet(
+29 -9
View File
@@ -23,7 +23,8 @@ class ChatList extends StatelessWidget {
const ChatList({super.key});
@override
Widget build(BuildContext context) => BlocModule<ChatListBloc, LoadableState<ChatListState>>(
Widget build(BuildContext context) =>
BlocModule<ChatListBloc, LoadableState<ChatListState>>(
create: (_) => ChatListBloc(),
child: (context, bloc, _) => const _ChatListView(),
);
@@ -83,16 +84,22 @@ class _ChatListViewState extends State<_ChatListView> {
void _maybeAskForNotificationPermission() {
final notificationSettings = _settings.val().notificationSettings;
if (notificationSettings.enabled || notificationSettings.askUsageDismissed) return;
if (notificationSettings.enabled ||
notificationSettings.askUsageDismissed) {
return;
}
_settings.val(write: true).notificationSettings.askUsageDismissed = true;
ConfirmDialog(
icon: Icons.notifications_active_outlined,
title: 'Benachrichtigungen aktivieren',
content: 'Auf wunsch kannst du Push-Benachrichtigungen aktivieren. Deine Einstellungen kannst du jederzeit ändern.',
content:
'Auf wunsch kannst du Push-Benachrichtigungen aktivieren. Deine Einstellungen kannst du jederzeit ändern.',
confirmButton: 'Weiter',
onConfirm: () {
FirebaseMessaging.instance.requestPermission(provisional: false).then((value) {
FirebaseMessaging.instance.requestPermission(provisional: false).then((
value,
) {
if (!mounted) return;
switch (value.authorizationStatus) {
case AuthorizationStatus.authorized:
@@ -129,7 +136,10 @@ class _ChatListViewState extends State<_ChatListView> {
onPressed: () {
final rooms = bloc.state.data?.rooms;
if (rooms == null) return;
showSearch(context: context, delegate: SearchChat(rooms.data.toList()));
showSearch(
context: context,
delegate: SearchChat(rooms.data.toList()),
);
},
),
],
@@ -138,11 +148,14 @@ class _ChatListViewState extends State<_ChatListView> {
heroTag: 'createChat',
backgroundColor: Theme.of(context).primaryColor,
onPressed: () {
showSearch(context: context, delegate: JoinChat()).then((username) {
showSearch(context: context, delegate: JoinChat()).then((
username,
) {
if (username == null || !context.mounted) return;
ConfirmDialog(
title: 'Chat starten',
content: "Möchtest du einen Chat mit Nutzer '$username' starten?",
content:
"Möchtest du einen Chat mit Nutzer '$username' starten?",
confirmButton: 'Chat starten',
onConfirmAsync: () => bloc.createDirectChat(username),
).asDialog(context);
@@ -155,7 +168,10 @@ class _ChatListViewState extends State<_ChatListView> {
final rooms = state.rooms;
if (rooms == null) return const SizedBox.shrink();
final talkSettings = context.watch<SettingsCubit>().val().talkSettings;
final talkSettings = context
.watch<SettingsCubit>()
.val()
.talkSettings;
final sorted = rooms.sortBy(
lastActivity: true,
favoritesToTop: talkSettings.sortFavoritesToTop,
@@ -172,7 +188,11 @@ class _ChatListViewState extends State<_ChatListView> {
return ListView(
padding: EdgeInsets.zero,
children: sorted.map((room) {
final hasDraft = _settings.val().talkSettings.drafts.containsKey(room.token);
final hasDraft = _settings
.val()
.talkSettings
.drafts
.containsKey(room.token);
return ChatTile(data: room, hasDraft: hasDraft);
}).toList(),
);
+98 -71
View File
@@ -20,7 +20,12 @@ class ChatView extends StatefulWidget {
final String selfId;
final UserAvatar avatar;
const ChatView({super.key, required this.room, required this.selfId, required this.avatar});
const ChatView({
super.key,
required this.room,
required this.selfId,
required this.avatar,
});
@override
State<ChatView> createState() => _ChatViewState();
@@ -37,46 +42,58 @@ class _ChatViewState extends State<ChatView> {
final messages = <Widget>[];
var lastDate = DateTime.now();
for (final element in response.sortByTimestamp()) {
final elementDate = DateTime.fromMillisecondsSinceEpoch(element.timestamp * 1000);
final elementDate = DateTime.fromMillisecondsSinceEpoch(
element.timestamp * 1000,
);
if (element.systemMessage.contains('reaction')) continue;
if (element.systemMessage.contains('poll_voted')) continue;
final commonRead = int.parse(response.headers?['x-chat-last-common-read'] ?? '0');
final commonRead = int.parse(
response.headers?['x-chat-last-common-read'] ?? '0',
);
if (!elementDate.isSameDay(lastDate)) {
lastDate = elementDate;
messages.add(ChatBubble(
context: context,
isSender: false,
bubbleData: GetChatResponseObject.getDateDummy(element.timestamp),
chatData: widget.room,
refetch: ({bool renew = false}) => _refresh(),
));
messages.add(
ChatBubble(
context: context,
isSender: false,
bubbleData: GetChatResponseObject.getDateDummy(element.timestamp),
chatData: widget.room,
refetch: ({bool renew = false}) => _refresh(),
),
);
}
messages.add(ChatBubble(
context: context,
isSender: element.actorId == widget.selfId &&
element.messageType == GetRoomResponseObjectMessageType.comment,
bubbleData: element,
chatData: widget.room,
refetch: ({bool renew = false}) => _refresh(),
isRead: element.id <= commonRead,
selfId: widget.selfId,
));
messages.add(
ChatBubble(
context: context,
isSender:
element.actorId == widget.selfId &&
element.messageType == GetRoomResponseObjectMessageType.comment,
bubbleData: element,
chatData: widget.room,
refetch: ({bool renew = false}) => _refresh(),
isRead: element.id <= commonRead,
selfId: widget.selfId,
),
);
}
if (response.data.length >= 200) {
messages.insert(0, ChatBubble(
context: context,
isSender: false,
bubbleData: GetChatResponseObject.getTextDummy(
'Zurzeit können in dieser App nur die letzten 200 vergangenen Nachrichten angezeigt werden. '
'Um ältere Nachrichten abzurufen verwende die Webversion unter https://cloud.marianum-fulda.de',
messages.insert(
0,
ChatBubble(
context: context,
isSender: false,
bubbleData: GetChatResponseObject.getTextDummy(
'Zurzeit können in dieser App nur die letzten 200 vergangenen Nachrichten angezeigt werden. '
'Um ältere Nachrichten abzurufen verwende die Webversion unter https://cloud.marianum-fulda.de',
),
chatData: widget.room,
refetch: ({bool renew = false}) => _refresh(),
),
chatData: widget.room,
refetch: ({bool renew = false}) => _refresh(),
));
);
}
return messages;
@@ -84,52 +101,62 @@ class _ChatViewState extends State<ChatView> {
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: const Color(0xffefeae2),
appBar: ClickableAppBar(
onTap: () => TalkNavigator.pushSplitView(context, ChatInfo(widget.room)),
appBar: AppBar(
title: Row(
children: [
widget.avatar,
const SizedBox(width: 10),
Expanded(
child: Text(widget.room.displayName, overflow: TextOverflow.ellipsis, maxLines: 1),
),
],
backgroundColor: const Color(0xffefeae2),
appBar: ClickableAppBar(
onTap: () => TalkNavigator.pushSplitView(context, ChatInfo(widget.room)),
appBar: AppBar(
title: Row(
children: [
widget.avatar,
const SizedBox(width: 10),
Expanded(
child: Text(
widget.room.displayName,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
),
],
),
body: DecoratedBox(
decoration: BoxDecoration(
image: DecorationImage(
image: const AssetImage('assets/background/chat.png'),
scale: 1.5,
opacity: 1,
repeat: ImageRepeat.repeat,
invertColors: AppTheme.isDarkMode(context),
),
),
body: DecoratedBox(
decoration: BoxDecoration(
image: DecorationImage(
image: const AssetImage('assets/background/chat.png'),
scale: 1.5,
opacity: 1,
repeat: ImageRepeat.repeat,
invertColors: AppTheme.isDarkMode(context),
),
),
child: Column(
children: [
Expanded(
child: LoadableStateConsumer<ChatBloc, ChatState>(
isReady: (state) =>
state.chatResponse != null &&
state.currentToken == widget.room.token,
child: (state, _) => ListView(
reverse: true,
controller: _listController,
children: _buildMessages(state.chatResponse!).reversed.toList(),
),
),
),
child: Column(
children: [
Expanded(
child: LoadableStateConsumer<ChatBloc, ChatState>(
isReady: (state) =>
state.chatResponse != null && state.currentToken == widget.room.token,
child: (state, _) => ListView(
reverse: true,
controller: _listController,
children: _buildMessages(state.chatResponse!).reversed.toList(),
ColoredBox(
color: Theme.of(context).colorScheme.surface,
child: TalkNavigator.isSecondaryVisible(context)
? ChatTextfield(widget.room.token, selfId: widget.selfId)
: SafeArea(
child: ChatTextfield(
widget.room.token,
selfId: widget.selfId,
),
),
),
),
ColoredBox(
color: Theme.of(context).colorScheme.surface,
child: TalkNavigator.isSecondaryVisible(context)
? ChatTextfield(widget.room.token, selfId: widget.selfId)
: SafeArea(child: ChatTextfield(widget.room.token, selfId: widget.selfId)),
),
],
),
),
);
],
),
),
);
}
@@ -8,7 +8,12 @@ extension ColorExtensions on Color {
final invertedR = 1.0 - r;
final invertedG = 1.0 - g;
final invertedB = 1.0 - b;
return Color.from(alpha: a, red: invertedR, green: invertedG, blue: invertedB);
return Color.from(
alpha: a,
red: invertedR,
green: invertedG,
blue: invertedB,
);
}
Color withWhite(int whiteValue) {
@@ -23,14 +28,18 @@ class ChatBubbleStyles {
ChatBubbleStyles(this.context);
BubbleStyle getSystemStyle() => BubbleStyle(
color: AppTheme.isDarkMode(context) ? const Color(0xff182229) : Colors.white,
color: AppTheme.isDarkMode(context)
? const Color(0xff182229)
: Colors.white,
elevation: 2,
margin: const BubbleEdges.only(bottom: 20, top: 10),
alignment: Alignment.center,
);
BubbleStyle getRemoteStyle(bool seamless) {
var color = AppTheme.isDarkMode(context) ? const Color(0xff202c33) : Colors.white;
var color = AppTheme.isDarkMode(context)
? const Color(0xff202c33)
: Colors.white;
return BubbleStyle(
nip: BubbleNip.leftTop,
color: seamless ? Colors.transparent : color,
@@ -41,7 +50,9 @@ class ChatBubbleStyles {
}
BubbleStyle getSelfStyle(bool seamless) {
var color = AppTheme.isDarkMode(context) ? const Color(0xff005c4b) : const Color(0xffd3d3d3);
var color = AppTheme.isDarkMode(context)
? const Color(0xff005c4b)
: const Color(0xffd3d3d3);
return BubbleStyle(
nip: BubbleNip.rightBottom,
color: seamless ? Colors.transparent : color,
+46 -37
View File
@@ -1,4 +1,3 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
@@ -19,20 +18,19 @@ class ChatMessage {
bool get containsFile => file != null;
ChatMessage({required this.originalMessage, this.originalData}) {
if(originalData?.containsKey('file') ?? false) {
if (originalData?.containsKey('file') ?? false) {
file = originalData?['file'];
}
content = RichObjectStringProcessor.parseToString(originalMessage, originalData);
content = RichObjectStringProcessor.parseToString(
originalMessage,
originalData,
);
}
Widget getWidget() {
var contentWidget = Linkify(text: content, onOpen: UrlOpener.onOpen);
var contentWidget = Linkify(
text: content,
onOpen: UrlOpener.onOpen,
);
if(originalData?['object']?.type == RichObjectStringObjectType.talkPoll) {
if (originalData?['object']?.type == RichObjectStringObjectType.talkPoll) {
return ListTile(
leading: const Icon(Icons.poll_outlined),
title: Text(originalData!['object']!.name),
@@ -40,38 +38,49 @@ class ChatMessage {
);
}
if(file == null) return contentWidget;
if (file == null) return contentWidget;
return Padding(
padding: const EdgeInsets.only(top: 5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CachedNetworkImage(
errorWidget: (context, url, error) => Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Icons.file_open_outlined, size: 35),
const SizedBox(width: 10),
Flexible(child: Text(file!.name, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.bold))),
const SizedBox(width: 10),
],
),
alignment: Alignment.center,
placeholder: (context, url) => const Padding(padding: EdgeInsets.all(15), child: SizedBox(width: 50, child: LinearProgressIndicator())),
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
errorListener: (value) {},
httpHeaders: AccountData().authHeaders(),
imageUrl: 'https://${EndpointData().nextcloud().full()}/index.php/core/preview?fileId=${file!.id}&x=130&y=-1&a=1',
padding: const EdgeInsets.only(top: 5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CachedNetworkImage(
errorWidget: (context, url, error) => Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Icons.file_open_outlined, size: 35),
const SizedBox(width: 10),
Flexible(
child: Text(
file!.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
const SizedBox(width: 10),
],
),
if(originalMessage != '{file}') ...[
SizedBox(height: 5),
contentWidget
]
alignment: Alignment.center,
placeholder: (context, url) => const Padding(
padding: EdgeInsets.all(15),
child: SizedBox(width: 50, child: LinearProgressIndicator()),
),
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
errorListener: (value) {},
httpHeaders: AccountData().authHeaders(),
imageUrl:
'https://${EndpointData().nextcloud().full()}/index.php/core/preview?fileId=${file!.id}&x=130&y=-1&a=1',
),
if (originalMessage != '{file}') ...[
SizedBox(height: 5),
contentWidget,
],
)
],
),
);
}
}
+23 -13
View File
@@ -28,18 +28,17 @@ class _ChatInfoState extends State<ChatInfo> {
setState(() {
participants = data;
});
}
},
);
super.initState();
}
@override
Widget build(BuildContext context) {
var isGroup = widget.room.type != GetRoomResponseObjectConversationType.oneToOne;
var isGroup =
widget.room.type != GetRoomResponseObjectConversationType.oneToOne;
return Scaffold(
appBar: AppBar(
title: Text(widget.room.displayName),
),
appBar: AppBar(title: Text(widget.room.displayName)),
body: Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
@@ -52,23 +51,34 @@ class _ChatInfoState extends State<ChatInfo> {
size: 80,
),
onTap: () {
if(isGroup) return;
TalkNavigator.pushSplitView(context, LargeProfilePictureView(widget.room.name));
if (isGroup) return;
TalkNavigator.pushSplitView(
context,
LargeProfilePictureView(widget.room.name),
);
},
),
const SizedBox(height: 30),
Text(widget.room.displayName, textAlign: TextAlign.center, style: const TextStyle(fontSize: 30)),
if(!isGroup) Text(widget.room.name),
Text(
widget.room.displayName,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 30),
),
if (!isGroup) Text(widget.room.name),
const SizedBox(height: 10),
if(isGroup) Text(widget.room.description, textAlign: TextAlign.center),
if (isGroup)
Text(widget.room.description, textAlign: TextAlign.center),
const SizedBox(height: 30),
if(participants == null) const LoadingSpinner(),
if(participants != null) ...[
if (participants == null) const LoadingSpinner(),
if (participants != null) ...[
ListTile(
leading: const Icon(Icons.supervised_user_circle),
title: Text('${participants!.data.length} Mitglieder'),
trailing: const Icon(Icons.arrow_right),
onTap: () => TalkNavigator.pushSplitView(context, ParticipantsListView(participants!)),
onTap: () => TalkNavigator.pushSplitView(
context,
ParticipantsListView(participants!),
),
),
],
],
@@ -13,7 +13,11 @@ import '../../../../widget/user_avatar.dart';
class MessageReactions extends StatefulWidget {
final String token;
final int messageId;
const MessageReactions({super.key, required this.token, required this.messageId});
const MessageReactions({
super.key,
required this.token,
required this.messageId,
});
@override
State<MessageReactions> createState() => _MessageReactionsState();
@@ -25,53 +29,67 @@ class _MessageReactionsState extends State<MessageReactions> {
@override
void initState() {
super.initState();
data = GetReactions(chatToken: widget.token, messageId: widget.messageId).run();
data = GetReactions(
chatToken: widget.token,
messageId: widget.messageId,
).run();
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('Reaktionen'),
),
body: FutureBuilder(
future: data,
builder: (context, snapshot) {
if(snapshot.connectionState == ConnectionState.waiting) return const LoadingSpinner();
if(snapshot.data!.data.isEmpty) return const PlaceholderView(icon: Icons.search_off_outlined, text: 'Keine Reaktionen gefunden!');
return ListView(
children: [
...snapshot.data!.data.entries.map<Widget>((entry) => ExpansionTile(
textColor: Theme.of(context).colorScheme.onSurface,
collapsedTextColor: Theme.of(context).colorScheme.onSurface,
iconColor: Theme.of(context).colorScheme.onSurface,
collapsedIconColor: Theme.of(context).colorScheme.onSurface,
appBar: AppBar(title: const Text('Reaktionen')),
body: FutureBuilder(
future: data,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const LoadingSpinner();
}
if (snapshot.data!.data.isEmpty) {
return const PlaceholderView(
icon: Icons.search_off_outlined,
text: 'Keine Reaktionen gefunden!',
);
}
return ListView(
children: [
...snapshot.data!.data.entries.map<Widget>(
(entry) => ExpansionTile(
textColor: Theme.of(context).colorScheme.onSurface,
collapsedTextColor: Theme.of(context).colorScheme.onSurface,
iconColor: Theme.of(context).colorScheme.onSurface,
collapsedIconColor: Theme.of(context).colorScheme.onSurface,
subtitle: const Text('Tippe für mehr'),
leading: CenteredLeading(Text(entry.key)),
title: Text('${entry.value.length} mal reagiert'),
children: entry.value.map((e) {
var isSelf = AccountData().getUsername() == e.actorId;
return ListTile(
leading: UserAvatar(id: e.actorId, isGroup: false),
title: Text(e.actorDisplayName),
subtitle: isSelf
subtitle: const Text('Tippe für mehr'),
leading: CenteredLeading(Text(entry.key)),
title: Text('${entry.value.length} mal reagiert'),
children: entry.value.map((e) {
var isSelf = AccountData().getUsername() == e.actorId;
return ListTile(
leading: UserAvatar(id: e.actorId, isGroup: false),
title: Text(e.actorDisplayName),
subtitle: isSelf
? const Text('Du')
: e.actorType == GetReactionsResponseObjectActorType.guests ? const Text('Gast') : null,
trailing: isSelf
: e.actorType ==
GetReactionsResponseObjectActorType.guests
? const Text('Gast')
: null,
trailing: isSelf
? null
: Visibility(
visible: kReleaseMode,
child: IconButton(
onPressed: () => UnimplementedDialog.show(context),
icon: const Icon(Icons.textsms_outlined),
visible: kReleaseMode,
child: IconButton(
onPressed: () =>
UnimplementedDialog.show(context),
icon: const Icon(Icons.textsms_outlined),
),
),
),
);
}).toList(),
))
],
);
},
),
);
);
}).toList(),
),
),
],
);
},
),
);
}
@@ -10,38 +10,46 @@ class ParticipantsListView extends StatelessWidget {
@override
Widget build(BuildContext context) {
String lastname(participant) => participant.displayName.toString().split(' ').last;
final participants = participantsResponse.data
.sorted((a, b) {
final typeComparison = a.participantType.index.compareTo(b.participantType.index);
if (typeComparison != 0) return typeComparison;
return lastname(a).compareTo(lastname(b));
});
var groupedParticipants = participants.groupListsBy((participant) => participant.participantType);
String lastname(participant) =>
participant.displayName.toString().split(' ').last;
final participants = participantsResponse.data.sorted((a, b) {
final typeComparison = a.participantType.index.compareTo(
b.participantType.index,
);
if (typeComparison != 0) return typeComparison;
return lastname(a).compareTo(lastname(b));
});
var groupedParticipants = participants.groupListsBy(
(participant) => participant.participantType,
);
return Scaffold(
appBar: AppBar(
title: const Text('Mitglieder'),
),
appBar: AppBar(title: const Text('Mitglieder')),
body: ListView(
children: [
...groupedParticipants.entries.map((entry) => Column(
children: [
ListTile(
title: Text(entry.key.prettyName),
titleTextStyle: Theme.of(context).textTheme.titleMedium
),
...entry.value.map((participant) => ListTile(
leading: UserAvatar(id: participant.actorId),
title: Text(participant.displayName),
subtitle: participant.statusMessage != null ? Text(participant.statusMessage!) : null,
)),
Divider(),
],
))
...groupedParticipants.entries.map(
(entry) => Column(
children: [
ListTile(
title: Text(entry.key.prettyName),
titleTextStyle: Theme.of(context).textTheme.titleMedium,
),
...entry.value.map(
(participant) => ListTile(
leading: UserAvatar(id: participant.actorId),
title: Text(participant.displayName),
subtitle: participant.statusMessage != null
? Text(participant.statusMessage!)
: null,
),
),
Divider(),
],
),
),
],
)
),
);
}
}
+14 -12
View File
@@ -1,4 +1,3 @@
import 'package:async/async.dart';
import 'package:flutter/material.dart';
@@ -14,10 +13,11 @@ class JoinChat extends SearchDelegate<String> {
@override
List<Widget>? buildActions(BuildContext context) => [
if(future != null && query.isNotEmpty) FutureBuilder(
if (future != null && query.isNotEmpty)
FutureBuilder(
future: future!.value,
builder: (context, snapshot) {
if(snapshot.connectionState != ConnectionState.done) {
if (snapshot.connectionState != ConnectionState.done) {
return const Padding(
padding: EdgeInsets.all(10),
child: Center(child: AppProgressIndicator.medium()),
@@ -26,17 +26,18 @@ class JoinChat extends SearchDelegate<String> {
return const SizedBox.shrink();
},
),
if(query.isNotEmpty) IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)),
];
if (query.isNotEmpty)
IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)),
];
@override
Widget? buildLeading(BuildContext context) => null;
@override
Widget buildResults(BuildContext context) {
if(future != null) future!.cancel();
if (future != null) future!.cancel();
if(query.isEmpty) {
if (query.isEmpty) {
return const PlaceholderView(
text: 'Suche nach benutzern',
icon: Icons.person_search_outlined,
@@ -47,13 +48,15 @@ class JoinChat extends SearchDelegate<String> {
return FutureBuilder<AutocompleteResponse>(
future: future!.value,
builder: (context, snapshot) {
if(snapshot.hasData) {
if (snapshot.hasData) {
return ListView.builder(
itemCount: snapshot.data!.data.length,
itemBuilder: (context, index) {
var object = snapshot.data!.data[index];
var circleAvatar = CircleAvatar(
foregroundImage: Image.network('https://${EndpointData().nextcloud().full()}/avatar/${object.id}/128').image,
foregroundImage: Image.network(
'https://${EndpointData().nextcloud().full()}/avatar/${object.id}/128',
).image,
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white,
child: const Icon(Icons.person),
@@ -67,9 +70,9 @@ class JoinChat extends SearchDelegate<String> {
close(context, object.id);
},
);
}
},
);
} else if(snapshot.hasError) {
} else if (snapshot.hasError) {
return PlaceholderView(
icon: Icons.search_off,
text: errorToUserMessage(snapshot.error),
@@ -83,5 +86,4 @@ class JoinChat extends SearchDelegate<String> {
@override
Widget buildSuggestions(BuildContext context) => buildResults(context);
}
+14 -5
View File
@@ -10,17 +10,26 @@ class SearchChat extends SearchDelegate<GetRoomResponseObject?> {
@override
List<Widget>? buildActions(BuildContext context) => [
if(query.isNotEmpty) IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)),
];
if (query.isNotEmpty)
IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)),
];
@override
Widget? buildLeading(BuildContext context) => null;
@override
Widget buildResults(BuildContext context) {
var items = chats.where(
(e) => e.displayName.toString().toLowerCase().contains(query.toLowerCase()) || e.name.toString().toLowerCase().contains(query.toLowerCase())
).toList()..sort((a, b) => b.lastActivity.compareTo(a.lastActivity));
var items =
chats
.where(
(e) =>
e.displayName.toString().toLowerCase().contains(
query.toLowerCase(),
) ||
e.name.toString().toLowerCase().contains(query.toLowerCase()),
)
.toList()
..sort((a, b) => b.lastActivity.compareTo(a.lastActivity));
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
+13 -6
View File
@@ -1,16 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter_split_view/flutter_split_view.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
class TalkNavigator {
static bool hasSplitViewState(BuildContext context) => context.findAncestorStateOfType<SplitViewState>() != null;
static bool isSecondaryVisible(BuildContext context) => hasSplitViewState(context) && SplitView.of(context).isSecondaryVisible;
static bool hasSplitViewState(BuildContext context) =>
context.findAncestorStateOfType<SplitViewState>() != null;
static bool isSecondaryVisible(BuildContext context) =>
hasSplitViewState(context) && SplitView.of(context).isSecondaryVisible;
static void pushSplitView(BuildContext context, Widget view, {bool overrideToSingleSubScreen = false}) {
if(isSecondaryVisible(context)) {
static void pushSplitView(
BuildContext context,
Widget view, {
bool overrideToSingleSubScreen = false,
}) {
if (isSecondaryVisible(context)) {
var splitView = SplitView.of(context);
overrideToSingleSubScreen ? splitView.setSecondary(view) : splitView.push(view);
overrideToSingleSubScreen
? splitView.setSecondary(view)
: splitView.push(view);
} else {
pushScreen(context, screen: view, withNavBar: false);
}
@@ -8,7 +8,12 @@ class AnswerReference extends StatelessWidget {
final BuildContext context;
final GetChatResponseObject referenceMessage;
final String? selfId;
const AnswerReference({required this.context, required this.referenceMessage, required this.selfId, super.key});
const AnswerReference({
required this.context,
required this.referenceMessage,
required this.selfId,
super.key,
});
@override
Widget build(BuildContext context) {
@@ -16,15 +21,25 @@ class AnswerReference extends StatelessWidget {
return DecoratedBox(
decoration: BoxDecoration(
color: referenceMessage.actorId == selfId
? style.getSelfStyle(false).color!.withGreen(200).withValues(alpha: 0.2)
: style.getRemoteStyle(false).color!.withWhite(200).withValues(alpha: 0.2),
? style
.getSelfStyle(false)
.color!
.withGreen(200)
.withValues(alpha: 0.2)
: style
.getRemoteStyle(false)
.color!
.withWhite(200)
.withValues(alpha: 0.2),
borderRadius: const BorderRadius.all(Radius.circular(5)),
border: Border(left: BorderSide(
border: Border(
left: BorderSide(
color: referenceMessage.actorId == selfId
? style.getSelfStyle(false).color!.withGreen(200)
: style.getRemoteStyle(false).color!.withWhite(200),
width: 5
)),
width: 5,
),
),
),
child: Padding(
padding: const EdgeInsets.all(5).add(const EdgeInsets.only(left: 5)),
@@ -43,7 +58,10 @@ class AnswerReference extends StatelessWidget {
),
),
Text(
RichObjectStringProcessor.parseToString(referenceMessage.message, referenceMessage.messageParameters),
RichObjectStringProcessor.parseToString(
referenceMessage.message,
referenceMessage.messageParameters,
),
maxLines: 2,
style: TextStyle(
overflow: TextOverflow.ellipsis,
+33 -9
View File
@@ -3,12 +3,17 @@ 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.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;
: top = value,
bottom = value,
left = value,
right = value;
final double top;
final double bottom;
@@ -53,9 +58,19 @@ class Bubble extends StatelessWidget {
final flat = Radius.zero;
switch (style.nip) {
case BubbleNip.leftTop:
return BorderRadius.only(topLeft: flat, topRight: r, bottomLeft: r, bottomRight: r);
return BorderRadius.only(
topLeft: flat,
topRight: r,
bottomLeft: r,
bottomRight: r,
);
case BubbleNip.rightBottom:
return BorderRadius.only(topLeft: r, topRight: r, bottomLeft: r, bottomRight: flat);
return BorderRadius.only(
topLeft: r,
topRight: r,
bottomLeft: r,
bottomRight: flat,
);
case BubbleNip.none:
return BorderRadius.all(r);
}
@@ -72,10 +87,19 @@ class Bubble extends StatelessWidget {
color: style.color,
borderRadius: radius,
border: style.borderWidth > 0
? Border.all(color: Theme.of(context).dividerColor, width: style.borderWidth)
? Border.all(
color: Theme.of(context).dividerColor,
width: style.borderWidth,
)
: null,
boxShadow: style.elevation > 0
? [BoxShadow(color: Colors.black26, blurRadius: style.elevation * 2, offset: Offset(0, style.elevation))]
? [
BoxShadow(
color: Colors.black26,
blurRadius: style.elevation * 2,
offset: Offset(0, style.elevation),
),
]
: null,
),
padding: style.padding.toEdgeInsets(),
+107 -80
View File
@@ -40,13 +40,15 @@ class ChatBubble extends StatefulWidget {
required this.refetch,
this.isRead = false,
this.selfId,
super.key});
super.key,
});
@override
State<ChatBubble> createState() => _ChatBubbleState();
}
class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateMixin {
class _ChatBubbleState extends State<ChatBubble>
with SingleTickerProviderStateMixin {
late ChatMessage message;
DownloadJob? _job;
@@ -109,7 +111,10 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
final file = message.file;
final filePath = file?.path;
if (file == null || filePath == null) return;
final job = await DownloadManager.instance.start(remotePath: filePath, name: file.name);
final job = await DownloadManager.instance.start(
remotePath: filePath,
name: file.name,
);
if (!mounted) return;
if (_job == job) return;
_detachJob();
@@ -129,19 +134,22 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
BubbleStyle _getStyle() {
final styles = ChatBubbleStyles(context);
if (widget.bubbleData.messageType != GetRoomResponseObjectMessageType.comment) {
if (widget.bubbleData.messageType !=
GetRoomResponseObjectMessageType.comment) {
return styles.getSystemStyle();
}
return widget.isSender ? styles.getSelfStyle(false) : styles.getRemoteStyle(false);
return widget.isSender
? styles.getSelfStyle(false)
: styles.getRemoteStyle(false);
}
void _showOptionsDialog() => showChatMessageOptionsDialog(
context,
chatData: widget.chatData,
bubbleData: widget.bubbleData,
isSender: widget.isSender,
onRefetch: widget.refetch,
);
context,
chatData: widget.chatData,
bubbleData: widget.bubbleData,
isSender: widget.isSender,
onRefetch: widget.refetch,
);
void _onTap() {
final obj = message.originalData?['object'];
@@ -165,24 +173,40 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
@override
Widget build(BuildContext context) {
message = ChatMessage(originalMessage: widget.bubbleData.message, originalData: widget.bubbleData.messageParameters);
final showActorDisplayName = widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment
&& widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne;
final showBubbleTime = widget.bubbleData.messageType != GetRoomResponseObjectMessageType.system
&& widget.bubbleData.messageType != GetRoomResponseObjectMessageType.deletedComment;
message = ChatMessage(
originalMessage: widget.bubbleData.message,
originalData: widget.bubbleData.messageParameters,
);
final showActorDisplayName =
widget.bubbleData.messageType ==
GetRoomResponseObjectMessageType.comment &&
widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne;
final showBubbleTime =
widget.bubbleData.messageType !=
GetRoomResponseObjectMessageType.system &&
widget.bubbleData.messageType !=
GetRoomResponseObjectMessageType.deletedComment;
final parent = widget.bubbleData.parent;
final actorText = Text(
widget.bubbleData.actorDisplayName,
textAlign: TextAlign.start,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold),
style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
);
final timeText = Text(
DateTime.fromMillisecondsSinceEpoch(widget.bubbleData.timestamp * 1000).formatHm(),
DateTime.fromMillisecondsSinceEpoch(
widget.bubbleData.timestamp * 1000,
).formatHm(),
textAlign: TextAlign.end,
style: TextStyle(color: widget.timeIconColor, fontSize: widget.timeIconSize),
style: TextStyle(
color: widget.timeIconColor,
fontSize: widget.timeIconSize,
),
);
return Column(
@@ -206,7 +230,9 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
final isAction = _position.dx.abs() > 50;
setState(() => _position = Offset.zero);
if (widget.bubbleData.isReplyable && isAction) {
context.read<ChatBloc>().setReferenceMessageId(widget.bubbleData.id);
context.read<ChatBloc>().setReferenceMessageId(
widget.bubbleData.id,
);
}
},
onLongPress: _showOptionsDialog,
@@ -281,67 +307,68 @@ class _BubbleContent extends StatelessWidget {
@override
Widget build(BuildContext context) => Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.9,
minWidth: showActorDisplayName
? actorText.size.width
: timeText.size.width + (isSender ? spacing + timeIconSize : 0) + 3,
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.9,
minWidth: showActorDisplayName
? actorText.size.width
: timeText.size.width + (isSender ? spacing + timeIconSize : 0) + 3,
),
child: Stack(
children: [
if (showActorDisplayName) Positioned(top: 0, left: 0, child: actorText),
Padding(
padding: EdgeInsets.only(
bottom: showBubbleTime ? 18 : 0,
top: showActorDisplayName ? 18 : 0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (parent != null &&
bubbleData.messageType ==
GetRoomResponseObjectMessageType.comment) ...[
AnswerReference(
context: context,
referenceMessage: parent!,
selfId: selfId,
),
const SizedBox(height: 5),
],
messageWidget,
],
),
),
child: Stack(
children: [
if (showActorDisplayName)
Positioned(top: 0, left: 0, child: actorText),
Padding(
padding: EdgeInsets.only(
bottom: showBubbleTime ? 18 : 0,
top: showActorDisplayName ? 18 : 0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (parent != null && bubbleData.messageType == GetRoomResponseObjectMessageType.comment) ...[
AnswerReference(
context: context,
referenceMessage: parent!,
selfId: selfId,
),
const SizedBox(height: 5),
],
messageWidget,
if (showBubbleTime)
Positioned(
bottom: 0,
right: 0,
child: Row(
children: [
timeText,
if (isSender) ...[
SizedBox(width: spacing),
Icon(
isRead ? Icons.done_all_outlined : Icons.done_outlined,
size: timeIconSize,
color: timeIconColor,
),
],
),
],
),
if (showBubbleTime)
Positioned(
bottom: 0,
right: 0,
child: Row(
children: [
timeText,
if (isSender) ...[
SizedBox(width: spacing),
Icon(
isRead ? Icons.done_all_outlined : Icons.done_outlined,
size: timeIconSize,
color: timeIconColor,
),
],
],
),
),
if (downloadJob?.status.value is DownloadInProgress)
Positioned(
bottom: 0,
right: 0,
left: 0,
child: LinearProgressIndicator(
value: () {
final s = downloadJob!.status.value as DownloadInProgress;
return s.percent <= 0 ? null : s.percent / 100;
}(),
),
),
],
),
);
),
if (downloadJob?.status.value is DownloadInProgress)
Positioned(
bottom: 0,
right: 0,
left: 0,
child: LinearProgressIndicator(
value: () {
final s = downloadJob!.status.value as DownloadInProgress;
return s.percent <= 0 ? null : s.percent / 100;
}(),
),
),
],
),
);
}
@@ -22,14 +22,14 @@ void showChatBubblePollDialog(
future: pollState,
builder: (_, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Column(mainAxisSize: MainAxisSize.min, children: [LoadingSpinner()]);
return const Column(
mainAxisSize: MainAxisSize.min,
children: [LoadingSpinner()],
);
}
final pollData = snapshot.data!.data;
return SingleChildScrollView(
child: PollOptionsList(
pollData: pollData,
chatToken: chatToken,
),
child: PollOptionsList(pollData: pollData, chatToken: chatToken),
);
},
),
@@ -37,14 +37,20 @@ class ChatBubbleReactions extends StatelessWidget {
alignment: isSender ? WrapAlignment.end : WrapAlignment.start,
crossAxisAlignment: WrapCrossAlignment.start,
children: reactions.entries.map<Widget>((e) {
final hasSelfReacted = bubbleData.reactionsSelf?.contains(e.key) ?? false;
final hasSelfReacted =
bubbleData.reactionsSelf?.contains(e.key) ?? false;
return Container(
margin: const EdgeInsets.only(right: 2.5, left: 2.5),
child: ActionChip(
label: Text('${e.key} ${e.value}'),
visualDensity: const VisualDensity(vertical: VisualDensity.minimumDensity, horizontal: VisualDensity.minimumDensity),
visualDensity: const VisualDensity(
vertical: VisualDensity.minimumDensity,
horizontal: VisualDensity.minimumDensity,
),
padding: EdgeInsets.zero,
backgroundColor: hasSelfReacted ? Theme.of(context).primaryColor : null,
backgroundColor: hasSelfReacted
? Theme.of(context).primaryColor
: null,
onPressed: () {
runWithErrorDialog(context, () async {
if (hasSelfReacted) {
@@ -29,11 +29,13 @@ void showChatMessageOptionsDialog(
required void Function({bool renew}) onRefetch,
}) {
final parentContext = context;
final canReact = bubbleData.messageType == GetRoomResponseObjectMessageType.comment;
final canDelete = isSender &&
DateTime.fromMillisecondsSinceEpoch(bubbleData.timestamp * 1000)
.add(const Duration(hours: 6))
.isAfter(DateTime.now());
final canReact =
bubbleData.messageType == GetRoomResponseObjectMessageType.comment;
final canDelete =
isSender &&
DateTime.fromMillisecondsSinceEpoch(
bubbleData.timestamp * 1000,
).add(const Duration(hours: 6)).isAfter(DateTime.now());
showDetailsBottomSheet(
context,
@@ -61,7 +63,11 @@ void showChatMessageOptionsDialog(
onTap: () {
Navigator.of(sheetCtx).pop();
if (!parentContext.mounted) return;
AppRoutes.openMessageReactions(parentContext, chatData.token, bubbleData.id);
AppRoutes.openMessageReactions(
parentContext,
chatData.token,
bubbleData.id,
);
},
),
if (bubbleData.message != '{file}')
@@ -73,7 +79,9 @@ void showChatMessageOptionsDialog(
Navigator.of(sheetCtx).pop();
},
),
if (!kReleaseMode && !isSender && chatData.type != GetRoomResponseObjectConversationType.oneToOne)
if (!kReleaseMode &&
!isSender &&
chatData.type != GetRoomResponseObjectConversationType.oneToOne)
ListTile(
leading: const Icon(Icons.sms_outlined),
title: Text("Private Nachricht an '${bubbleData.actorDisplayName}'"),
@@ -136,54 +144,57 @@ class _ReactionsRowState extends State<_ReactionsRow> {
@override
Widget build(BuildContext context) => AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final busy = _controller.busy;
final err = _controller.error;
return Column(
mainAxisSize: MainAxisSize.min,
animation: _controller,
builder: (context, _) {
final busy = _controller.busy;
final err = _controller.error;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Wrap(
alignment: WrapAlignment.center,
children: [
Wrap(
alignment: WrapAlignment.center,
children: [
..._commonReactions.map(
(emoji) => TextButton(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(40, 40),
),
onPressed: busy ? null : () => _react(emoji),
child: Text(emoji),
),
),
IconButton(
onPressed: busy ? null : () => _showEmojiPicker(context),
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(40, 40),
),
icon: busy
? const AppProgressIndicator.small()
: const Icon(Icons.add_circle_outline_outlined),
),
],
),
if (err != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text(
err,
textAlign: TextAlign.center,
style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 12),
..._commonReactions.map(
(emoji) => TextButton(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(40, 40),
),
onPressed: busy ? null : () => _react(emoji),
child: Text(emoji),
),
const Divider(),
),
IconButton(
onPressed: busy ? null : () => _showEmojiPicker(context),
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(40, 40),
),
icon: busy
? const AppProgressIndicator.small()
: const Icon(Icons.add_circle_outline_outlined),
),
],
);
},
),
if (err != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text(
err,
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
),
const Divider(),
],
);
},
);
void _showEmojiPicker(BuildContext rowContext) {
showDialog(
@@ -214,7 +225,9 @@ class _ReactionsRowState extends State<_ReactionsRow> {
noRecents: const Text('Keine zuletzt verwendeten Emojis'),
columns: 7,
),
bottomActionBarConfig: const emojis.BottomActionBarConfig(enabled: false),
bottomActionBarConfig: const emojis.BottomActionBarConfig(
enabled: false,
),
categoryViewConfig: emojis.CategoryViewConfig(
backgroundColor: Theme.of(pickerCtx).hoverColor,
iconColorSelected: Theme.of(pickerCtx).primaryColor,
+178 -130
View File
@@ -39,13 +39,17 @@ class _ChatTextfieldState extends State<ChatTextfield> {
void share(String shareFolder, List<String> filePaths) {
for (final element in filePaths) {
final fileName = element.split(Platform.pathSeparator).last;
FileSharingApi().share(FileSharingApiParams(
shareType: 10,
shareWith: widget.sendToToken,
path: '$shareFolder/$fileName',
)).then((_) {
if (mounted) context.read<ChatBloc>().refresh();
});
FileSharingApi()
.share(
FileSharingApiParams(
shareType: 10,
shareWith: widget.sendToToken,
path: '$shareFolder/$fileName',
),
)
.then((_) {
if (mounted) context.read<ChatBloc>().refresh();
});
}
}
@@ -53,19 +57,25 @@ class _ChatTextfieldState extends State<ChatTextfield> {
if (paths == null) return;
const shareFolder = 'MarianumMobile';
unawaited(WebdavApi.webdav.then((webdav) => webdav.mkcol(PathUri.parse('/$shareFolder'))));
unawaited(
WebdavApi.webdav.then(
(webdav) => webdav.mkcol(PathUri.parse('/$shareFolder')),
),
);
if (!mounted) return;
unawaited(pushScreen(
context,
withNavBar: false,
screen: FilesUploadDialog(
filePaths: paths,
remotePath: shareFolder,
onUploadFinished: (uploaded) => share(shareFolder, uploaded),
uniqueNames: true,
unawaited(
pushScreen(
context,
withNavBar: false,
screen: FilesUploadDialog(
filePaths: paths,
remotePath: shareFolder,
onUploadFinished: (uploaded) => share(shareFolder, uploaded),
uniqueNames: true,
),
),
));
);
}
void _setDraft(String text) {
@@ -82,7 +92,9 @@ class _ChatTextfieldState extends State<ChatTextfield> {
if (messageId != null) {
talkSettings.draftReplies[widget.sendToToken] = messageId;
} else {
talkSettings.draftReplies.removeWhere((key, _) => key == widget.sendToToken);
talkSettings.draftReplies.removeWhere(
(key, _) => key == widget.sendToToken,
);
}
}
@@ -90,7 +102,10 @@ class _ChatTextfieldState extends State<ChatTextfield> {
void initState() {
super.initState();
settings = context.read<SettingsCubit>();
final draftReply = settings.val().talkSettings.draftReplies[widget.sendToToken];
final draftReply = settings
.val()
.talkSettings
.draftReplies[widget.sendToToken];
if (draftReply != null) {
context.read<ChatBloc>().setReferenceMessageId(draftReply);
}
@@ -121,16 +136,19 @@ class _ChatTextfieldState extends State<ChatTextfield> {
@override
Widget build(BuildContext context) {
_textBoxController.text = settings.val().talkSettings.drafts[widget.sendToToken] ?? '';
_textBoxController.text =
settings.val().talkSettings.drafts[widget.sendToToken] ?? '';
final chatBloc = context.watch<ChatBloc>();
final chatState = chatBloc.state.data;
Widget replyBanner = const SizedBox.shrink();
if (chatState != null && chatState.referenceMessageId != null && chatState.chatResponse != null) {
if (chatState != null &&
chatState.referenceMessageId != null &&
chatState.chatResponse != null) {
try {
final referenceMessage = chatState.chatResponse!.sortByTimestamp().firstWhere(
(e) => e.id == chatState.referenceMessageId,
);
final referenceMessage = chatState.chatResponse!
.sortByTimestamp()
.firstWhere((e) => e.id == chatState.referenceMessageId);
replyBanner = Row(
children: [
Expanded(
@@ -150,120 +168,150 @@ class _ChatTextfieldState extends State<ChatTextfield> {
),
],
);
} catch (_) {/* reference no longer in current chat data */}
} catch (_) {
/* reference no longer in current chat data */
}
}
return Stack(children: <Widget>[
Align(
alignment: Alignment.bottomLeft,
child: Container(
padding: const EdgeInsets.only(left: 10, bottom: 3, top: 3, right: 10),
width: double.infinity,
child: Column(
children: [
replyBanner,
if (_sendError != null)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
_sendError!,
style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 12),
),
),
Row(children: <Widget>[
GestureDetector(
onTap: () {
showDetailsBottomSheet(
context,
children: (sheetCtx) => [
ListTile(
leading: const Icon(Icons.file_open),
title: const Text('Aus Dateien auswählen'),
onTap: () {
FilePick.documentPick().then(mediaUpload);
Navigator.of(sheetCtx).pop();
},
),
ListTile(
leading: const Icon(Icons.image),
title: const Text('Aus Galerie auswählen'),
onTap: () {
FilePick.multipleGalleryPick().then((value) {
if (value != null) mediaUpload(value.map((e) => e.path).toList());
});
Navigator.of(sheetCtx).pop();
},
),
ListTile(
leading: const Icon(Icons.camera_alt_outlined),
title: const Text('Foto aufnehmen'),
onTap: () {
FilePick.cameraPick().then((image) {
if (image != null) mediaUpload([image.path]);
});
Navigator.of(sheetCtx).pop();
},
),
],
);
},
child: Material(
elevation: 5,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(30)),
child: Container(
height: 30,
width: 30,
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
borderRadius: BorderRadius.circular(30),
return Stack(
children: <Widget>[
Align(
alignment: Alignment.bottomLeft,
child: Container(
padding: const EdgeInsets.only(
left: 10,
bottom: 3,
top: 3,
right: 10,
),
width: double.infinity,
child: Column(
children: [
replyBanner,
if (_sendError != null)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
_sendError!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
child: const Icon(Icons.attach_file_outlined, color: Colors.white, size: 20),
),
),
),
const SizedBox(width: 15),
Expanded(
child: TextField(
autocorrect: true,
textCapitalization: TextCapitalization.sentences,
controller: _textBoxController,
maxLines: 7,
minLines: 1,
decoration: const InputDecoration(
hintText: 'Nachricht schreiben...',
border: InputBorder.none,
Row(
children: <Widget>[
GestureDetector(
onTap: () {
showDetailsBottomSheet(
context,
children: (sheetCtx) => [
ListTile(
leading: const Icon(Icons.file_open),
title: const Text('Aus Dateien auswählen'),
onTap: () {
FilePick.documentPick().then(mediaUpload);
Navigator.of(sheetCtx).pop();
},
),
ListTile(
leading: const Icon(Icons.image),
title: const Text('Aus Galerie auswählen'),
onTap: () {
FilePick.multipleGalleryPick().then((value) {
if (value != null) {
mediaUpload(
value.map((e) => e.path).toList(),
);
}
});
Navigator.of(sheetCtx).pop();
},
),
ListTile(
leading: const Icon(Icons.camera_alt_outlined),
title: const Text('Foto aufnehmen'),
onTap: () {
FilePick.cameraPick().then((image) {
if (image != null) mediaUpload([image.path]);
});
Navigator.of(sheetCtx).pop();
},
),
],
);
},
child: Material(
elevation: 5,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
child: Container(
height: 30,
width: 30,
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
borderRadius: BorderRadius.circular(30),
),
child: const Icon(
Icons.attach_file_outlined,
color: Colors.white,
size: 20,
),
),
),
),
onChanged: (text) {
if (text.trim().toLowerCase() == 'marbot marbot marbot') {
const newText = 'Roboter sind cool und so, aber Marbots sind besser!';
_textBoxController.text = newText;
text = newText;
}
_setDraft(text);
},
onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context),
),
const SizedBox(width: 15),
Expanded(
child: TextField(
autocorrect: true,
textCapitalization: TextCapitalization.sentences,
controller: _textBoxController,
maxLines: 7,
minLines: 1,
decoration: const InputDecoration(
hintText: 'Nachricht schreiben...',
border: InputBorder.none,
),
onChanged: (text) {
if (text.trim().toLowerCase() ==
'marbot marbot marbot') {
const newText =
'Roboter sind cool und so, aber Marbots sind besser!';
_textBoxController.text = newText;
text = newText;
}
_setDraft(text);
},
onTapOutside: (_) =>
FocusBehaviour.textFieldTapOutside(context),
),
),
const SizedBox(width: 15),
ValueListenableBuilder<TextEditingValue>(
valueListenable: _textBoxController,
builder: (context, value, _) => AsyncFab(
mini: true,
heroTag: 'chatSend_${widget.sendToToken}',
icon: Icons.send,
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white,
controller: _sendController,
onPressed: value.text.trim().isEmpty
? null
: () => _sendMessage(chatBloc),
onError: (message) =>
setState(() => _sendError = message),
onSuccess: () => setState(() => _sendError = null),
),
),
],
),
const SizedBox(width: 15),
ValueListenableBuilder<TextEditingValue>(
valueListenable: _textBoxController,
builder: (context, value, _) => AsyncFab(
mini: true,
heroTag: 'chatSend_${widget.sendToToken}',
icon: Icons.send,
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white,
controller: _sendController,
onPressed: value.text.trim().isEmpty ? null : () => _sendMessage(chatBloc),
onError: (message) => setState(() => _sendError = message),
onSuccess: () => setState(() => _sendError = null),
),
),
]),
],
],
),
),
),
),
]);
],
);
}
}
+47 -12
View File
@@ -25,7 +25,12 @@ class ChatTile extends StatefulWidget {
final bool disableContextActions;
final bool hasDraft;
const ChatTile({super.key, required this.data, this.disableContextActions = false, this.hasDraft = false});
const ChatTile({
super.key,
required this.data,
this.disableContextActions = false,
this.hasDraft = false,
});
@override
State<ChatTile> createState() => _ChatTileState();
@@ -39,7 +44,11 @@ class _ChatTileState extends State<ChatTile> {
super.initState();
AccountData().waitForPopulation().then((_) {
if (!mounted) return;
setState(() => selfUsername = AccountData().isPopulated() ? AccountData().getUsername() : null);
setState(
() => selfUsername = AccountData().isPopulated()
? AccountData().getUsername()
: null,
);
});
}
@@ -49,7 +58,9 @@ class _ChatTileState extends State<ChatTile> {
await SetReadMarker(
widget.data.token,
true,
setReadMarkerParams: SetReadMarkerParams(lastReadMessage: widget.data.lastMessage.id),
setReadMarkerParams: SetReadMarkerParams(
lastReadMessage: widget.data.lastMessage.id,
),
).run();
if (!mounted) return;
_refreshList();
@@ -58,12 +69,18 @@ class _ChatTileState extends State<ChatTile> {
@override
Widget build(BuildContext context) {
final chatBloc = context.watch<ChatBloc>();
final isGroup = widget.data.type != GetRoomResponseObjectConversationType.oneToOne;
final circleAvatar = UserAvatar(id: isGroup ? widget.data.token : widget.data.name, isGroup: isGroup);
final isGroup =
widget.data.type != GetRoomResponseObjectConversationType.oneToOne;
final circleAvatar = UserAvatar(
id: isGroup ? widget.data.token : widget.data.name,
isGroup: isGroup,
);
return ListTile(
style: ListTileStyle.list,
tileColor: chatBloc.state.data?.currentToken == widget.data.token && TalkNavigator.isSecondaryVisible(context)
tileColor:
chatBloc.state.data?.currentToken == widget.data.token &&
TalkNavigator.isSecondaryVisible(context)
? Theme.of(context).primaryColor.withAlpha(100)
: null,
leading: Stack(
@@ -80,16 +97,25 @@ class _ChatTileState extends State<ChatTile> {
color: Theme.of(context).primaryColor.withAlpha(200),
borderRadius: BorderRadius.circular(90.0),
),
child: const Icon(Icons.star, color: Colors.amberAccent, size: 15),
child: const Icon(
Icons.star,
color: Colors.amberAccent,
size: 15,
),
),
),
)
),
],
),
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(child: Text(widget.data.displayName, overflow: TextOverflow.ellipsis)),
Flexible(
child: Text(
widget.data.displayName,
overflow: TextOverflow.ellipsis,
),
),
if (widget.hasDraft) ...[
const SizedBox(width: 5),
const Icon(Icons.edit_outlined, size: 15),
@@ -119,8 +145,16 @@ class _ChatTileState extends State<ChatTile> {
onTap: () {
if (selfUsername == null) return;
unawaited(_setCurrentAsRead());
final view = ChatView(room: widget.data, selfId: selfUsername!, avatar: circleAvatar);
TalkNavigator.pushSplitView(context, view, overrideToSingleSubScreen: true);
final view = ChatView(
room: widget.data,
selfId: selfUsername!,
avatar: circleAvatar,
);
TalkNavigator.pushSplitView(
context,
view,
overrideToSingleSubScreen: true,
);
context.read<ChatBloc>().setToken(widget.data.token);
},
onLongPress: () {
@@ -168,7 +202,8 @@ class _ChatTileState extends State<ChatTile> {
Navigator.of(sheetCtx).pop();
ConfirmDialog(
title: 'Chat verlassen',
content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.',
content:
'Du benötigst ggf. eine Einladung um erneut beizutreten.',
confirmButton: 'Verlassen',
onConfirmAsync: () async {
await LeaveRoom(widget.data.token).run();
@@ -1,4 +1,3 @@
import 'package:flutter/material.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
@@ -8,7 +7,11 @@ import '../../../../utils/url_opener.dart';
class PollOptionsList extends StatefulWidget {
final GetPollStateResponseObject pollData;
final String chatToken;
const PollOptionsList({super.key, required this.pollData, required this.chatToken});
const PollOptionsList({
super.key,
required this.pollData,
required this.chatToken,
});
@override
State<PollOptionsList> createState() => _PollOptionsListState();
@@ -23,44 +26,48 @@ class _PollOptionsListState extends State<PollOptionsList> {
var votedSelf = widget.pollData.votedSelf.contains(optionId);
var portionsVisible = widget.pollData.votes is Map<String, dynamic>;
var votes = portionsVisible
? (widget.pollData.votes['option-$optionId'] as num?) ?? 0
: 0;
? (widget.pollData.votes['option-$optionId'] as num?) ?? 0
: 0;
var numVoters = widget.pollData.numVoters ?? 0;
final portion = numVoters == 0 ? 0.0 : (votes / numVoters);
return ListTile(
isThreeLine: portionsVisible,
dense: true,
title: Text(
option,
style: Theme.of(context).textTheme.bodyLarge,
),
title: Text(option, style: Theme.of(context).textTheme.bodyLarge),
leading: Icon(
votedSelf ? Icons.check_circle_outlined : Icons.circle_outlined,
color: votedSelf
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.6)
: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
subtitle: portionsVisible ? Row(
children: [
Expanded(
child: LinearProgressIndicator(value: portion.clamp(0.0, 1.0)),
),
Container(
margin: const EdgeInsets.only(left: 10),
child: Text('${(portion * 100).round()}%'),
),
],
) : null,
subtitle: portionsVisible
? Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: portion.clamp(0.0, 1.0),
),
),
Container(
margin: const EdgeInsets.only(left: 10),
child: Text('${(portion * 100).round()}%'),
),
],
)
: null,
);
}),
ListTile(
title: Linkify(
text: 'Wenn du abstimmen möchtest, verwende die Webversion unter https://cloud.marianum-fulda.de/call/${widget.chatToken}',
text:
'Wenn du abstimmen möchtest, verwende die Webversion unter https://cloud.marianum-fulda.de/call/${widget.chatToken}',
onOpen: UrlOpener.onOpen,
style: Theme.of(context).textTheme.bodySmall,
),
)
),
],
);
}
@@ -7,21 +7,25 @@ class SplitViewPlaceholder extends StatelessWidget {
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
MediaQuery(
data: MediaQuery.of(context).copyWith(
invertColors: !AppTheme.isDarkMode(context),
),
child: Image.asset('assets/logo/icon.png', height: 200),
),
const SizedBox(height: 30),
const Text('Marianum Fulda\nTalk', textAlign: TextAlign.center, style: TextStyle(fontSize: 30)),
],
),
)
);
appBar: AppBar(),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
MediaQuery(
data: MediaQuery.of(
context,
).copyWith(invertColors: !AppTheme.isDarkMode(context)),
child: Image.asset('assets/logo/icon.png', height: 200),
),
const SizedBox(height: 30),
const Text(
'Marianum Fulda\nTalk',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 30),
),
],
),
),
);
}
@@ -5,7 +5,8 @@ import '../../../../theming/dark_app_theme.dart';
enum CustomTimetableColors { orange, red, green, blue }
class TimetableColors {
static const CustomTimetableColors defaultColor = CustomTimetableColors.orange;
static const CustomTimetableColors defaultColor =
CustomTimetableColors.orange;
static ColorModeDisplay getDisplayOptions(CustomTimetableColors color) {
switch (color) {
@@ -14,17 +15,24 @@ class TimetableColors {
case CustomTimetableColors.blue:
return ColorModeDisplay(color: Colors.blue, displayName: 'Blau');
case CustomTimetableColors.orange:
return ColorModeDisplay(color: Colors.orange.shade800, displayName: 'Orange');
return ColorModeDisplay(
color: Colors.orange.shade800,
displayName: 'Orange',
);
case CustomTimetableColors.red:
return ColorModeDisplay(color: DarkAppTheme.marianumRed, displayName: 'Rot');
return ColorModeDisplay(
color: DarkAppTheme.marianumRed,
displayName: 'Rot',
);
}
}
static Color getColorFromString(String color) =>
getDisplayOptions(CustomTimetableColors.values.firstWhere(
(e) => e.name == color,
orElse: () => defaultColor,
)).color;
static Color getColorFromString(String color) => getDisplayOptions(
CustomTimetableColors.values.firstWhere(
(e) => e.name == color,
orElse: () => defaultColor,
),
).color;
}
class ColorModeDisplay {
@@ -42,7 +42,8 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
static const TimeOfDay _defaultEnd = TimeOfDay(hour: 9, minute: 30);
static const int _minDurationMinutes = 15;
late DateTime _date = widget.existingEvent?.startDate ?? widget.initialStart ?? DateTime.now();
late DateTime _date =
widget.existingEvent?.startDate ?? widget.initialStart ?? DateTime.now();
late TimeOfDay _startTime;
late TimeOfDay _endTime;
late bool _isAllDay;
@@ -85,13 +86,18 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
_endTime = clamped.$2;
}
static (TimeOfDay, TimeOfDay) _clampToVisibleWindow(TimeOfDay rawStart, TimeOfDay rawEnd) {
static (TimeOfDay, TimeOfDay) _clampToVisibleWindow(
TimeOfDay rawStart,
TimeOfDay rawEnd,
) {
int toMin(TimeOfDay t) => t.hour * 60 + t.minute;
TimeOfDay fromMin(int m) => TimeOfDay(hour: m ~/ 60, minute: m % 60);
final windowStart = toMin(_windowStart);
final windowEnd = toMin(_windowEnd);
var start = toMin(rawStart).clamp(windowStart, windowEnd - _minDurationMinutes);
var start = toMin(
rawStart,
).clamp(windowStart, windowEnd - _minDurationMinutes);
var end = toMin(rawEnd);
if (end < start + _minDurationMinutes) end = start + _minDurationMinutes;
if (end > windowEnd) {
@@ -165,10 +171,7 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
context: context,
start: _startTime,
end: _endTime,
disabledTime: TimeRange(
startTime: _windowEnd,
endTime: _windowStart,
),
disabledTime: TimeRange(startTime: _windowEnd, endTime: _windowStart),
disabledColor: Colors.grey,
paintingStyle: PaintingStyle.fill,
interval: const Duration(minutes: 5),
@@ -188,103 +191,118 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
@override
Widget build(BuildContext context) => AlertDialog(
insetPadding: const EdgeInsets.all(20),
contentPadding: const EdgeInsets.all(10),
title: Text(_isEditing ? 'Termin bearbeiten' : 'Termin hinzufügen'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: TextField(
controller: _name,
autofocus: true,
decoration: const InputDecoration(labelText: 'Terminname', border: OutlineInputBorder()),
onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context),
),
insetPadding: const EdgeInsets.all(20),
contentPadding: const EdgeInsets.all(10),
title: Text(_isEditing ? 'Termin bearbeiten' : 'Termin hinzufügen'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: TextField(
controller: _name,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Terminname',
border: OutlineInputBorder(),
),
ListTile(
title: TextField(
controller: _description,
maxLines: 2,
minLines: 2,
decoration: const InputDecoration(labelText: 'Beschreibung', border: OutlineInputBorder()),
onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context),
),
),
const Divider(),
ListTile(
leading: const Icon(Icons.date_range_outlined),
title: Text(Jiffy.parseFromDateTime(_date).yMMMd),
subtitle: const Text('Datum'),
onTap: _pickDate,
),
SwitchListTile(
secondary: const Icon(Icons.today_outlined),
title: const Text('Ganztägig'),
value: _isAllDay,
onChanged: (v) => setState(() => _isAllDay = v),
),
if (!_isAllDay)
ListTile(
leading: const Icon(Icons.access_time_outlined),
title: Text('${_startTime.format(context)} - ${_endTime.format(context)}'),
subtitle: const Text('Zeitraum'),
onTap: _pickTimeRange,
),
const Divider(),
ListTile(
leading: const Icon(Icons.color_lens_outlined),
title: const Text('Farbgebung'),
trailing: DropdownButton<CustomTimetableColors>(
value: _color,
icon: const Icon(Icons.arrow_drop_down),
items: CustomTimetableColors.values
.map((e) => DropdownMenuItem<CustomTimetableColors>(
value: e,
enabled: e != _color,
child: Row(
children: [
Icon(Icons.circle, color: TimetableColors.getDisplayOptions(e).color),
const SizedBox(width: 10),
Text(TimetableColors.getDisplayOptions(e).displayName),
],
),
))
.toList(),
onChanged: (e) => setState(() => _color = e!),
),
),
const Divider(),
RRuleGenerator(
config: RRuleGeneratorConfig(
selectDayStyle: RRuleSelectDayStyle(
dayStyle: BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(context).colorScheme.secondary,
),
dayTextStyle: const TextStyle(color: Colors.black),
selectedDayStyle: BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(context).primaryColor,
),
),
),
initialRRule: _rrule,
locale: RRuleLocale.de_DE,
onChange: (newValue) {
log('Rule: $newValue');
setState(() => _rrule = newValue);
},
),
],
onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context),
),
),
),
actions: [
AsyncDialogAction(
confirmLabel: _isEditing ? 'Speichern' : 'Erstellen',
onConfirm: _save,
ListTile(
title: TextField(
controller: _description,
maxLines: 2,
minLines: 2,
decoration: const InputDecoration(
labelText: 'Beschreibung',
border: OutlineInputBorder(),
),
onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context),
),
),
const Divider(),
ListTile(
leading: const Icon(Icons.date_range_outlined),
title: Text(Jiffy.parseFromDateTime(_date).yMMMd),
subtitle: const Text('Datum'),
onTap: _pickDate,
),
SwitchListTile(
secondary: const Icon(Icons.today_outlined),
title: const Text('Ganztägig'),
value: _isAllDay,
onChanged: (v) => setState(() => _isAllDay = v),
),
if (!_isAllDay)
ListTile(
leading: const Icon(Icons.access_time_outlined),
title: Text(
'${_startTime.format(context)} - ${_endTime.format(context)}',
),
subtitle: const Text('Zeitraum'),
onTap: _pickTimeRange,
),
const Divider(),
ListTile(
leading: const Icon(Icons.color_lens_outlined),
title: const Text('Farbgebung'),
trailing: DropdownButton<CustomTimetableColors>(
value: _color,
icon: const Icon(Icons.arrow_drop_down),
items: CustomTimetableColors.values
.map(
(e) => DropdownMenuItem<CustomTimetableColors>(
value: e,
enabled: e != _color,
child: Row(
children: [
Icon(
Icons.circle,
color: TimetableColors.getDisplayOptions(e).color,
),
const SizedBox(width: 10),
Text(
TimetableColors.getDisplayOptions(e).displayName,
),
],
),
),
)
.toList(),
onChanged: (e) => setState(() => _color = e!),
),
),
const Divider(),
RRuleGenerator(
config: RRuleGeneratorConfig(
selectDayStyle: RRuleSelectDayStyle(
dayStyle: BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(context).colorScheme.secondary,
),
dayTextStyle: const TextStyle(color: Colors.black),
selectedDayStyle: BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(context).primaryColor,
),
),
),
initialRRule: _rrule,
locale: RRuleLocale.de_DE,
onChange: (newValue) {
log('Rule: $newValue');
setState(() => _rrule = newValue);
},
),
],
);
),
),
actions: [
AsyncDialogAction(
confirmLabel: _isEditing ? 'Speichern' : 'Erstellen',
onConfirm: _save,
),
],
);
}
@@ -22,57 +22,69 @@ class CustomEventsView extends StatelessWidget {
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('Eigene Termine'),
actions: [
IconButton(
icon: const Icon(Icons.add),
appBar: AppBar(
title: const Text('Eigene Termine'),
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () => _openCreateDialog(context),
),
],
),
body: LoadableStateConsumer<TimetableBloc, TimetableState>(
child: (state, _) {
final events = state.customEvents?.events ?? const [];
if (events.isEmpty) {
return PlaceholderView(
icon: Icons.calendar_today_outlined,
text: 'Keine Einträge vorhanden',
button: TextButton(
onPressed: () => _openCreateDialog(context),
child: const Text('Termin erstellen'),
),
],
),
body: LoadableStateConsumer<TimetableBloc, TimetableState>(
child: (state, _) {
final events = state.customEvents?.events ?? const [];
);
}
if (events.isEmpty) {
return PlaceholderView(
icon: Icons.calendar_today_outlined,
text: 'Keine Einträge vorhanden',
button: TextButton(
onPressed: () => _openCreateDialog(context),
child: const Text('Termin erstellen'),
return ListView(
children: events
.map(
(e) => ListTile(
title: Text(e.title),
subtitle: Text(
'${e.rrule.isNotEmpty ? "wiederholend, " : ""}'
'beginnend ${e.startDate.formatRelative()}',
),
leading: CenteredLeading(
Icon(
e.rrule.isEmpty
? Icons.event_outlined
: Icons.event_repeat_outlined,
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () => showDialog(
context: context,
builder: (_) =>
CustomEventEditDialog(existingEvent: e),
),
),
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () =>
showDeleteCustomEventDialog(context, e),
),
],
),
),
);
}
return ListView(
children: events.map((e) => ListTile(
title: Text(e.title),
subtitle: Text(
'${e.rrule.isNotEmpty ? "wiederholend, " : ""}'
'beginnend ${e.startDate.formatRelative()}',
),
leading: CenteredLeading(Icon(e.rrule.isEmpty ? Icons.event_outlined : Icons.event_repeat_outlined)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () => showDialog(
context: context,
builder: (_) => CustomEventEditDialog(existingEvent: e),
),
),
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => showDeleteCustomEventDialog(context, e),
),
],
),
)).toList(),
);
},
),
);
)
.toList(),
);
},
),
);
}
@@ -8,9 +8,9 @@ sealed class ArbitraryAppointment {
required T Function(GetTimetableResponseObject lesson) webuntis,
required T Function(CustomTimetableEvent event) custom,
}) => switch (this) {
WebuntisAppointment(:final lesson) => webuntis(lesson),
CustomAppointment(:final event) => custom(event),
};
WebuntisAppointment(:final lesson) => webuntis(lesson),
CustomAppointment(:final event) => custom(event),
};
}
class WebuntisAppointment extends ArbitraryAppointment {
@@ -43,24 +43,28 @@ List<BoundRegion> expandRegionsForDay(List<TimeRegion> regions, DateTime day) {
final result = <BoundRegion>[];
final dayStart = DateTime(day.year, day.month, day.day);
for (final region in regions) {
final isRecurringDaily = region.recurrenceRule != null &&
final isRecurringDaily =
region.recurrenceRule != null &&
region.recurrenceRule!.toUpperCase().contains('FREQ=DAILY');
if (isRecurringDaily) {
final start = dayStart.add(Duration(
hours: region.startTime.hour,
minutes: region.startTime.minute,
));
final end = dayStart.add(Duration(
hours: region.endTime.hour,
minutes: region.endTime.minute,
));
final start = dayStart.add(
Duration(
hours: region.startTime.hour,
minutes: region.startTime.minute,
),
);
final end = dayStart.add(
Duration(hours: region.endTime.hour, minutes: region.endTime.minute),
);
result.add(BoundRegion(region: region, start: start, end: end));
} else if (region.startTime.isSameDay(day)) {
result.add(BoundRegion(
region: region,
start: region.startTime,
end: region.endTime,
));
result.add(
BoundRegion(
region: region,
start: region.startTime,
end: region.endTime,
),
);
}
}
return result;
@@ -73,8 +77,10 @@ List<BoundRegion> expandRegionsForDay(List<TimeRegion> regions, DateTime day) {
/// [kCalendarStartHour] or end after [kCalendarEndHour]). The outside bucket
/// is rendered as chips above the grid.
({List<List<Appointment>> inside, List<List<Appointment>> outside})
partitionAppointmentsForWeek(
List<Appointment> appointments, DateTime weekStart) {
partitionAppointmentsForWeek(
List<Appointment> appointments,
DateTime weekStart,
) {
final inside = List<List<Appointment>>.generate(5, (_) => <Appointment>[]);
final outside = List<List<Appointment>>.generate(5, (_) => <Appointment>[]);
final weekEnd = weekStart.add(const Duration(days: 5));
@@ -104,12 +110,19 @@ List<BoundRegion> expandRegionsForDay(List<TimeRegion> regions, DateTime day) {
if (!occUtc.isBefore(weekEndUtc)) break;
if (occUtc.isBefore(weekStartUtc)) continue;
final occLocal = occUtc.toLocal();
final idx = DateTime(occLocal.year, occLocal.month, occLocal.day)
.difference(weekStart)
.inDays;
final idx = DateTime(
occLocal.year,
occLocal.month,
occLocal.day,
).difference(weekStart).inDays;
if (idx < 0 || idx >= 5) continue;
final newStart = DateTime(occLocal.year, occLocal.month, occLocal.day,
a.startTime.hour, a.startTime.minute);
final newStart = DateTime(
occLocal.year,
occLocal.month,
occLocal.day,
a.startTime.hour,
a.startTime.minute,
);
place(
idx,
Appointment(
@@ -150,8 +163,7 @@ class PeriodLayout {
double _h(LessonPeriod p) => p.isBreak ? breakHeight : lessonHeight;
double get totalHeight =>
periods.fold<double>(0, (sum, p) => sum + _h(p));
double get totalHeight => periods.fold<double>(0, (sum, p) => sum + _h(p));
double topOf(LessonPeriod period) {
var y = 0.0;
@@ -241,7 +253,13 @@ class LaidOutOverflow extends LaidOutCell {
final DateTime startTime;
@override
final DateTime endTime;
LaidOutOverflow(this.appointments, this.lane, this.laneCount, this.startTime, this.endTime);
LaidOutOverflow(
this.appointments,
this.lane,
this.laneCount,
this.startTime,
this.endTime,
);
}
/// Horizontal ordering rank for parallel appointments. Lower = further left.
@@ -269,17 +287,21 @@ int _appointmentPriority(Appointment a) {
/// is free at its `startTime`. When no lane is free, open a new one.
/// 3. A cluster ends as soon as every active lane's end is at or before the
/// next appointment's start.
List<LaidOutCell> assignLanes(List<Appointment> appts, {required int maxLanes}) {
List<LaidOutCell> assignLanes(
List<Appointment> appts, {
required int maxLanes,
}) {
assert(maxLanes >= 2, 'maxLanes must reserve at least one slot for overflow');
if (appts.isEmpty) return const <LaidOutCell>[];
final sorted = [...appts]..sort((a, b) {
final c = a.startTime.compareTo(b.startTime);
if (c != 0) return c;
final p = _appointmentPriority(a).compareTo(_appointmentPriority(b));
if (p != 0) return p;
return b.endTime.compareTo(a.endTime);
});
final sorted = [...appts]
..sort((a, b) {
final c = a.startTime.compareTo(b.startTime);
if (c != 0) return c;
final p = _appointmentPriority(a).compareTo(_appointmentPriority(b));
if (p != 0) return p;
return b.endTime.compareTo(a.endTime);
});
// Phase 1: greedy lane assignment, grouped by cluster.
final clusters = <List<({Appointment apt, int lane})>>[];
@@ -288,7 +310,8 @@ List<LaidOutCell> assignLanes(List<Appointment> appts, {required int maxLanes})
for (final apt in sorted) {
final allFree =
laneEnds.isNotEmpty && laneEnds.every((end) => !end.isAfter(apt.startTime));
laneEnds.isNotEmpty &&
laneEnds.every((end) => !end.isAfter(apt.startTime));
if (allFree) {
clusters.add(current);
current = <({Appointment apt, int lane})>[];
@@ -316,8 +339,10 @@ List<LaidOutCell> assignLanes(List<Appointment> appts, {required int maxLanes})
// Phase 2: emit cells per cluster, collapsing if too wide.
final result = <LaidOutCell>[];
for (final cluster in clusters) {
final laneCount =
cluster.fold<int>(0, (m, e) => e.lane + 1 > m ? e.lane + 1 : m);
final laneCount = cluster.fold<int>(
0,
(m, e) => e.lane + 1 > m ? e.lane + 1 : m,
);
if (laneCount <= maxLanes) {
for (final entry in cluster) {
@@ -348,8 +373,9 @@ List<LaidOutCell> assignLanes(List<Appointment> appts, {required int maxLanes})
if (a.startTime.isBefore(earliest)) earliest = a.startTime;
if (a.endTime.isAfter(latest)) latest = a.endTime;
}
result.add(LaidOutOverflow(
overflow, maxLanes - 1, maxLanes, earliest, latest));
result.add(
LaidOutOverflow(overflow, maxLanes - 1, maxLanes, earliest, latest),
);
}
}
return result;
@@ -17,8 +17,8 @@ class LessonPeriod {
});
Duration get duration => Duration(
minutes: (end.hour * 60 + end.minute) - (start.hour * 60 + start.minute),
);
minutes: (end.hour * 60 + end.minute) - (start.hour * 60 + start.minute),
);
int get _startMinutes => start.hour * 60 + start.minute;
}
@@ -31,39 +31,94 @@ class LessonPeriodSchedule {
static LessonPeriodSchedule? fromApi(GetTimegridUnitsResponse response) {
final canonical = response.result.firstWhere(
(d) => d.day == 1,
orElse: () => response.result.isNotEmpty ? response.result.first : GetTimegridUnitsResponseDay(0, []),
orElse: () => response.result.isNotEmpty
? response.result.first
: GetTimegridUnitsResponseDay(0, []),
);
if (canonical.timeUnits.isEmpty) return null;
final periods = canonical.timeUnits
.map((u) => LessonPeriod(
name: u.name,
start: _fromHHMM(u.startTime),
end: _fromHHMM(u.endTime),
))
.toList()
..sort((a, b) => a._startMinutes.compareTo(b._startMinutes));
final periods =
canonical.timeUnits
.map(
(u) => LessonPeriod(
name: u.name,
start: _fromHHMM(u.startTime),
end: _fromHHMM(u.endTime),
),
)
.toList()
..sort((a, b) => a._startMinutes.compareTo(b._startMinutes));
return LessonPeriodSchedule(periods);
}
static LessonPeriodSchedule fallback() => const LessonPeriodSchedule([
LessonPeriod(name: '0', start: TimeOfDay(hour: 7, minute: 10), end: TimeOfDay(hour: 7, minute: 53)),
LessonPeriod(name: '1', start: TimeOfDay(hour: 7, minute: 55), end: TimeOfDay(hour: 8, minute: 40)),
LessonPeriod(name: '2', start: TimeOfDay(hour: 8, minute: 40), end: TimeOfDay(hour: 9, minute: 25)),
LessonPeriod(name: '3', start: TimeOfDay(hour: 9, minute: 30), end: TimeOfDay(hour: 10, minute: 15)),
LessonPeriod(name: '4', start: TimeOfDay(hour: 10, minute: 35), end: TimeOfDay(hour: 11, minute: 20)),
LessonPeriod(name: '5', start: TimeOfDay(hour: 11, minute: 25), end: TimeOfDay(hour: 12, minute: 10)),
LessonPeriod(name: '6', start: TimeOfDay(hour: 12, minute: 15), end: TimeOfDay(hour: 13, minute: 0)),
LessonPeriod(name: '7', start: TimeOfDay(hour: 13, minute: 5), end: TimeOfDay(hour: 13, minute: 50)),
LessonPeriod(name: '8', start: TimeOfDay(hour: 14, minute: 5), end: TimeOfDay(hour: 14, minute: 50)),
LessonPeriod(name: '9', start: TimeOfDay(hour: 14, minute: 50), end: TimeOfDay(hour: 15, minute: 35)),
LessonPeriod(name: '10', start: TimeOfDay(hour: 15, minute: 40), end: TimeOfDay(hour: 16, minute: 25)),
LessonPeriod(name: '11', start: TimeOfDay(hour: 16, minute: 25), end: TimeOfDay(hour: 17, minute: 10)),
]);
LessonPeriod(
name: '0',
start: TimeOfDay(hour: 7, minute: 10),
end: TimeOfDay(hour: 7, minute: 53),
),
LessonPeriod(
name: '1',
start: TimeOfDay(hour: 7, minute: 55),
end: TimeOfDay(hour: 8, minute: 40),
),
LessonPeriod(
name: '2',
start: TimeOfDay(hour: 8, minute: 40),
end: TimeOfDay(hour: 9, minute: 25),
),
LessonPeriod(
name: '3',
start: TimeOfDay(hour: 9, minute: 30),
end: TimeOfDay(hour: 10, minute: 15),
),
LessonPeriod(
name: '4',
start: TimeOfDay(hour: 10, minute: 35),
end: TimeOfDay(hour: 11, minute: 20),
),
LessonPeriod(
name: '5',
start: TimeOfDay(hour: 11, minute: 25),
end: TimeOfDay(hour: 12, minute: 10),
),
LessonPeriod(
name: '6',
start: TimeOfDay(hour: 12, minute: 15),
end: TimeOfDay(hour: 13, minute: 0),
),
LessonPeriod(
name: '7',
start: TimeOfDay(hour: 13, minute: 5),
end: TimeOfDay(hour: 13, minute: 50),
),
LessonPeriod(
name: '8',
start: TimeOfDay(hour: 14, minute: 5),
end: TimeOfDay(hour: 14, minute: 50),
),
LessonPeriod(
name: '9',
start: TimeOfDay(hour: 14, minute: 50),
end: TimeOfDay(hour: 15, minute: 35),
),
LessonPeriod(
name: '10',
start: TimeOfDay(hour: 15, minute: 40),
end: TimeOfDay(hour: 16, minute: 25),
),
LessonPeriod(
name: '11',
start: TimeOfDay(hour: 16, minute: 25),
end: TimeOfDay(hour: 17, minute: 10),
),
]);
static LessonPeriodSchedule fromState(TimetableState state) {
final fromApi = state.timegrid != null ? LessonPeriodSchedule.fromApi(state.timegrid!) : null;
final fromApi = state.timegrid != null
? LessonPeriodSchedule.fromApi(state.timegrid!)
: null;
return (fromApi ?? fallback()).withSyntheticBreaks();
}
@@ -74,21 +129,22 @@ class LessonPeriodSchedule {
result.add(current);
if (i + 1 >= periods.length) continue;
final next = periods[i + 1];
final gapMinutes = next._startMinutes - (current.end.hour * 60 + current.end.minute);
final gapMinutes =
next._startMinutes - (current.end.hour * 60 + current.end.minute);
if (gapMinutes >= 10) {
result.add(LessonPeriod(
name: 'Pause',
start: current.end,
end: next.start,
isBreak: true,
));
result.add(
LessonPeriod(
name: 'Pause',
start: current.end,
end: next.start,
isBreak: true,
),
);
}
}
return LessonPeriodSchedule(result);
}
static TimeOfDay _fromHHMM(int hhmm) => TimeOfDay(
hour: hhmm ~/ 100,
minute: hhmm % 100,
);
static TimeOfDay _fromHHMM(int hhmm) =>
TimeOfDay(hour: hhmm ~/ 100, minute: hhmm % 100);
}
@@ -20,10 +20,17 @@ class LessonStatusClassifier {
}) {
if (lesson.code == 'cancelled') return LessonStatus.cancelled;
if (isEvent) return LessonStatus.event;
if (lesson.code == 'irregular' || (lesson.te.isNotEmpty && lesson.te.first.id == 0)) return LessonStatus.irregular;
if (lesson.te.any((t) => t.orgname != null)) return LessonStatus.teacherChanged;
if (lesson.code == 'irregular' ||
(lesson.te.isNotEmpty && lesson.te.first.id == 0)) {
return LessonStatus.irregular;
}
if (lesson.te.any((t) => t.orgname != null)) {
return LessonStatus.teacherChanged;
}
if (endTime.isBefore(now)) return LessonStatus.past;
if (startTime.isBefore(now) && endTime.isAfter(now)) return LessonStatus.ongoing;
if (startTime.isBefore(now) && endTime.isAfter(now)) {
return LessonStatus.ongoing;
}
return LessonStatus.regular;
}
}
@@ -31,7 +31,9 @@ class TimetableAppointmentFactory {
});
List<Appointment> build() {
final source = settings.connectDoubleLessons ? _mergeAdjacentLessons(lessons) : lessons;
final source = settings.connectDoubleLessons
? _mergeAdjacentLessons(lessons)
: lessons;
return [
...source.map(_lessonToAppointment),
...customEvents.map(_customEventToAppointment),
@@ -42,7 +44,9 @@ class TimetableAppointmentFactory {
try {
final startTime = WebuntisTime.parse(lesson.date, lesson.startTime);
final endTime = WebuntisTime.parse(lesson.date, lesson.endTime);
final subject = subjects.result.firstWhereOrNull((s) => s.id == lesson.su.firstOrNull?.id);
final subject = subjects.result.firstWhereOrNull(
(s) => s.id == lesson.su.firstOrNull?.id,
);
final status = LessonStatusClassifier.classify(
lesson,
startTime,
@@ -81,16 +85,26 @@ class TimetableAppointmentFactory {
id: CustomAppointment(event),
startTime: event.startDate,
endTime: allDay
? DateTime(event.startDate.year, event.startDate.month, event.startDate.day, 23, 59)
? DateTime(
event.startDate.year,
event.startDate.month,
event.startDate.day,
23,
59,
)
: event.endDate,
isAllDay: allDay,
// Preserve user-entered newlines in descriptions; the tile soft-wraps to
// fill the available height. For lessons we still collapse whitespace
// so room/teacher stay on one line each.
location: event.description.trim().isEmpty ? null : event.description.trim(),
location: event.description.trim().isEmpty
? null
: event.description.trim(),
subject: _collapseWhitespace(event.title) ?? event.title,
recurrenceRule: event.rrule,
color: TimetableColors.getColorFromString(event.color ?? TimetableColors.defaultColor.name),
color: TimetableColors.getColorFromString(
event.color ?? TimetableColors.defaultColor.name,
),
startTimeZone: '',
endTimeZone: '',
);
@@ -114,7 +128,10 @@ class TimetableAppointmentFactory {
e.second == 0;
}
String _subjectName(GetTimetableResponseObject lesson, GetSubjectsResponseObject? subject) {
String _subjectName(
GetTimetableResponseObject lesson,
GetSubjectsResponseObject? subject,
) {
if (subject == null) return 'Event';
final name = switch (settings.timetableNameMode) {
TimetableNameMode.name => subject.name,
@@ -125,10 +142,15 @@ class TimetableAppointmentFactory {
}
String _locationLabel(GetTimetableResponseObject lesson) {
final roomName = _collapseWhitespace(
rooms.result.firstWhereOrNull((r) => r.id == lesson.ro.firstOrNull?.id)?.name) ??
final roomName =
_collapseWhitespace(
rooms.result
.firstWhereOrNull((r) => r.id == lesson.ro.firstOrNull?.id)
?.name,
) ??
'Unbekannt';
final teacherName = _collapseWhitespace(lesson.te.firstOrNull?.longname) ?? 'Unbekannt';
final teacherName =
_collapseWhitespace(lesson.te.firstOrNull?.longname) ?? 'Unbekannt';
return '$roomName\n$teacherName';
}
@@ -161,8 +183,13 @@ class TimetableAppointmentFactory {
}) {
if (input.isEmpty) return const [];
final sorted = [...input]..sort((a, b) =>
WebuntisTime.parse(a.date, a.startTime).compareTo(WebuntisTime.parse(b.date, b.startTime)));
final sorted = [...input]
..sort(
(a, b) => WebuntisTime.parse(
a.date,
a.startTime,
).compareTo(WebuntisTime.parse(b.date, b.startTime)),
);
final merged = <GetTimetableResponseObject>[];
for (final current in sorted) {
@@ -180,10 +207,16 @@ class TimetableAppointmentFactory {
static GetTimetableResponseObject _copyLesson(GetTimetableResponseObject l) =>
GetTimetableResponseObject.fromJson(l.toJson());
static bool _canMerge(GetTimetableResponseObject a, GetTimetableResponseObject b, Duration maxGap) {
static bool _canMerge(
GetTimetableResponseObject a,
GetTimetableResponseObject b,
Duration maxGap,
) {
final aSubject = a.su.firstOrNull?.id;
final bSubject = b.su.firstOrNull?.id;
if (aSubject == null || bSubject == null || aSubject != bSubject) return false;
if (aSubject == null || bSubject == null || aSubject != bSubject) {
return false;
}
if (a.ro.firstOrNull?.id != b.ro.firstOrNull?.id) return false;
if (a.te.firstOrNull?.id != b.te.firstOrNull?.id) return false;
if (a.code != b.code) return false;
@@ -193,7 +226,10 @@ class TimetableAppointmentFactory {
// overlap in time would silently collapse into one — and because the
// merge sets `previous.endTime = current.endTime`, an overlapping merge
// can even truncate the earlier lesson.
final gap = WebuntisTime.parse(b.date, b.startTime).difference(WebuntisTime.parse(a.date, a.endTime));
final gap = WebuntisTime.parse(
b.date,
b.startTime,
).difference(WebuntisTime.parse(a.date, a.endTime));
return !gap.isNegative && gap <= maxGap;
}
}
@@ -8,11 +8,20 @@ class TimetableNameModes {
static DropdownDisplay getDisplayOptions(TimetableNameMode mode) {
switch (mode) {
case TimetableNameMode.name:
return DropdownDisplay(icon: Icons.device_unknown_outlined, displayName: 'Name');
return DropdownDisplay(
icon: Icons.device_unknown_outlined,
displayName: 'Name',
);
case TimetableNameMode.longName:
return DropdownDisplay(icon: Icons.perm_device_info_outlined, displayName: 'Langname');
return DropdownDisplay(
icon: Icons.perm_device_info_outlined,
displayName: 'Langname',
);
case TimetableNameMode.alternateName:
return DropdownDisplay(icon: Icons.on_device_training_outlined, displayName: 'Kurzform');
return DropdownDisplay(
icon: Icons.on_device_training_outlined,
displayName: 'Kurzform',
);
}
}
}
@@ -5,7 +5,9 @@ class WebuntisTime {
static DateTime parse(int date, int time) {
final timeString = time.toString().padLeft(4, '0');
return DateTime.parse('$date ${timeString.substring(0, 2)}:${timeString.substring(2, 4)}');
return DateTime.parse(
'$date ${timeString.substring(0, 2)}:${timeString.substring(2, 4)}',
);
}
static int formatDate(DateTime date) => int.parse(_dateFormat.format(date));
@@ -7,12 +7,17 @@ import 'custom_event_sheet.dart';
import 'webuntis_lesson_sheet.dart';
class AppointmentDetailsDispatcher {
static void show(BuildContext context, TimetableBloc bloc, Appointment appointment) {
static void show(
BuildContext context,
TimetableBloc bloc,
Appointment appointment,
) {
final id = appointment.id;
if (id is! ArbitraryAppointment) return;
id.when(
webuntis: (lesson) => WebuntisLessonSheet.show(context, bloc, appointment, lesson),
webuntis: (lesson) =>
WebuntisLessonSheet.show(context, bloc, appointment, lesson),
custom: (event) => CustomEventSheet.show(context, event),
);
}
@@ -17,7 +17,10 @@ class CustomEventSheet {
context,
header: ListTile(
leading: const Icon(Icons.event_outlined, size: 32),
title: Text(event.title, style: const TextStyle(fontWeight: FontWeight.bold)),
title: Text(
event.title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(timeRange),
),
children: (sheetCtx) => [
@@ -31,7 +34,8 @@ class CustomEventSheet {
Navigator.of(sheetCtx).pop();
showDialog(
context: context,
builder: (_) => CustomEventEditDialog(existingEvent: event),
builder: (_) =>
CustomEventEditDialog(existingEvent: event),
);
},
label: const Text('Bearbeiten'),
@@ -39,7 +43,9 @@ class CustomEventSheet {
),
TextButton.icon(
onPressed: () {
showDeleteCustomEventDialog(context, event).future.then((_) {
showDeleteCustomEventDialog(context, event).future.then((
_,
) {
if (!sheetCtx.mounted) return;
Navigator.of(sheetCtx).pop();
});
@@ -54,18 +60,28 @@ class CustomEventSheet {
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.info_outline),
title: Text(event.description.isEmpty ? 'Keine Beschreibung' : event.description),
title: Text(
event.description.isEmpty
? 'Keine Beschreibung'
: event.description,
),
),
ListTile(
leading: const CenteredLeading(Icon(Icons.repeat_outlined)),
title: Text('Serie: ${event.rrule.isNotEmpty ? "Wiederholend" : "Einmalig"}'),
title: Text(
'Serie: ${event.rrule.isNotEmpty ? "Wiederholend" : "Einmalig"}',
),
subtitle: FutureBuilder(
future: RruleL10nEn.create(),
builder: (_, snapshot) {
if (event.rrule.isEmpty) return const Text('Keine weiteren Vorkommnisse');
if (event.rrule.isEmpty) {
return const Text('Keine weiteren Vorkommnisse');
}
if (snapshot.data == null) return const Text('...');
final rrule = RecurrenceRule.fromString(event.rrule);
if (!rrule.canFullyConvertToText) return const Text('Keine genauere Angabe möglich.');
if (!rrule.canFullyConvertToText) {
return const Text('Keine genauere Angabe möglich.');
}
return Text(rrule.toText(l10n: snapshot.data!));
},
),
@@ -7,12 +7,16 @@ import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../../../widget/confirm_dialog.dart';
Completer<void> showDeleteCustomEventDialog(BuildContext context, CustomTimetableEvent event) {
Completer<void> showDeleteCustomEventDialog(
BuildContext context,
CustomTimetableEvent event,
) {
final completer = Completer<void>();
final bloc = context.read<TimetableBloc>();
ConfirmDialog(
title: 'Termin löschen',
content: 'Der ${event.rrule.isEmpty ? "Termin" : "Serientermin"} wird unwiederruflich gelöscht.',
content:
'Der ${event.rrule.isEmpty ? "Termin" : "Serientermin"} wird unwiederruflich gelöscht.',
confirmButton: 'Löschen',
onConfirmAsync: () async {
await bloc.removeCustomEvent(event.id);
@@ -14,13 +14,30 @@ import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/unimplemented_dialog.dart';
class WebuntisLessonSheet {
static void show(BuildContext context, TimetableBloc bloc, Appointment appointment, GetTimetableResponseObject lesson) {
static void show(
BuildContext context,
TimetableBloc bloc,
Appointment appointment,
GetTimetableResponseObject lesson,
) {
final state = bloc.state.data;
if (state == null) return;
final headerSubject = LessonResolver.resolveSubject(state, lesson.su.firstOrNull?.id);
final headerTitle = firstNonEmpty([headerSubject.alternateName, headerSubject.name, headerSubject.longName, '?']);
final headerLongName = headerSubject.longName.isNotEmpty && headerSubject.longName != headerTitle ? headerSubject.longName : '';
final headerSubject = LessonResolver.resolveSubject(
state,
lesson.su.firstOrNull?.id,
);
final headerTitle = firstNonEmpty([
headerSubject.alternateName,
headerSubject.name,
headerSubject.longName,
'?',
]);
final headerLongName =
headerSubject.longName.isNotEmpty &&
headerSubject.longName != headerTitle
? headerSubject.longName
: '';
final timeRange = appointment.startTime.timeRangeTo(appointment.endTime);
@@ -32,9 +49,9 @@ class WebuntisLessonSheet {
'${LessonFormatter.codePrefix(lesson.code)}$headerTitle',
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(headerLongName.isNotEmpty
? '$timeRange\n$headerLongName'
: timeRange),
subtitle: Text(
headerLongName.isNotEmpty ? '$timeRange\n$headerLongName' : timeRange,
),
isThreeLine: headerLongName.isNotEmpty,
),
children: (_) => <Widget>[
@@ -66,10 +83,12 @@ class WebuntisLessonSheet {
icon: Icons.people,
label: lesson.kl.length == 1 ? 'Klasse' : 'Klassen',
entries: lesson.kl
.map((k) => LessonFormatter.formatLine(
k.name.isNotEmpty ? k.name : '?',
longname: k.longname,
))
.map(
(k) => LessonFormatter.formatLine(
k.name.isNotEmpty ? k.name : '?',
longname: k.longname,
),
)
.toList(),
),
..._optionalTextTiles(lesson),
@@ -78,7 +97,11 @@ class WebuntisLessonSheet {
);
}
static Widget _roomTile(BuildContext context, TimetableState state, GetTimetableResponseObject lesson) {
static Widget _roomTile(
BuildContext context,
TimetableState state,
GetTimetableResponseObject lesson,
) {
final trailing = IconButton(
icon: const Icon(Icons.house_outlined),
onPressed: () => AppRoutes.openRoomplan(context),
@@ -112,7 +135,10 @@ class WebuntisLessonSheet {
);
}
static Widget _teacherTile(BuildContext context, GetTimetableResponseObject lesson) {
static Widget _teacherTile(
BuildContext context,
GetTimetableResponseObject lesson,
) {
final trailing = Visibility(
visible: !kReleaseMode,
child: IconButton(
+25 -8
View File
@@ -27,7 +27,8 @@ class Timetable extends StatefulWidget {
}
class _TimetableState extends State<Timetable> {
final GlobalKey<CustomWorkWeekCalendarState> _calendarKey = GlobalKey<CustomWorkWeekCalendarState>();
final GlobalKey<CustomWorkWeekCalendarState> _calendarKey =
GlobalKey<CustomWorkWeekCalendarState>();
List<Appointment>? _cachedAppointments;
int? _lastDataVersion;
@@ -53,7 +54,10 @@ class _TimetableState extends State<Timetable> {
}
List<Appointment> _appointments(TimetableState state) {
final timetableSettings = context.watch<SettingsCubit>().val().timetableSettings;
final timetableSettings = context
.watch<SettingsCubit>()
.val()
.timetableSettings;
if (_cachedAppointments != null &&
_lastDataVersion == state.dataVersion &&
identical(_lastTimetableSettings, timetableSettings)) {
@@ -81,7 +85,11 @@ class _TimetableState extends State<Timetable> {
bool _isOnInitialWeek(TimetableState state) {
final target = _initialDisplayDate();
final targetMonday = target.subtract(Duration(days: target.weekday - 1));
final mondayOnly = DateTime(targetMonday.year, targetMonday.month, targetMonday.day);
final mondayOnly = DateTime(
targetMonday.year,
targetMonday.month,
targetMonday.day,
);
return state.startDate == mondayOnly;
}
@@ -105,7 +113,10 @@ class _TimetableState extends State<Timetable> {
itemBuilder: (_) => const [
PopupMenuItem(
value: _CalendarAction.addEvent,
child: ListTile(title: Text('Kalendereintrag hinzufügen'), leading: Icon(Icons.add)),
child: ListTile(
title: Text('Kalendereintrag hinzufügen'),
leading: Icon(Icons.add),
),
),
PopupMenuItem(
value: _CalendarAction.viewEvents,
@@ -142,9 +153,14 @@ class _TimetableState extends State<Timetable> {
appointments: appointments,
timeRegions: regions,
initialDate: _initialDisplayDate(),
minDate: DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.sunday),
maxDate: DateTime.now().add(const Duration(days: 7)).nextWeekday(DateTime.saturday),
onAppointmentTap: (apt) => AppointmentDetailsDispatcher.show(context, bloc, apt),
minDate: DateTime.now()
.subtract(const Duration(days: 14))
.nextWeekday(DateTime.sunday),
maxDate: DateTime.now()
.add(const Duration(days: 7))
.nextWeekday(DateTime.saturday),
onAppointmentTap: (apt) =>
AppointmentDetailsDispatcher.show(context, bloc, apt),
onWeekChanged: (start, end) => bloc.changeWeek(start, end),
isCrossedOut: _isCrossedOut,
onCreateEvent: _onCreateEventAt,
@@ -154,7 +170,8 @@ class _TimetableState extends State<Timetable> {
void _onCreateEventAt(DateTime start, DateTime end) {
showDialog(
context: context,
builder: (_) => CustomEventEditDialog(initialStart: start, initialEnd: end),
builder: (_) =>
CustomEventEditDialog(initialStart: start, initialEnd: end),
barrierDismissible: false,
);
}
@@ -11,7 +11,11 @@ class AppointmentTile extends StatelessWidget {
final Appointment appointment;
final bool crossedOut;
const AppointmentTile({super.key, required this.appointment, this.crossedOut = false});
const AppointmentTile({
super.key,
required this.appointment,
this.crossedOut = false,
});
@override
Widget build(BuildContext context) {
@@ -56,11 +60,15 @@ class AppointmentTile extends StatelessWidget {
),
),
] else ...[
for (final line in description
.split('\n')
.where((p) => p.isNotEmpty)
.take(2))
_ScaledLine(text: line, fontSize: kAppointmentBodyFontSize),
for (final line
in description
.split('\n')
.where((p) => p.isNotEmpty)
.take(2))
_ScaledLine(
text: line,
fontSize: kAppointmentBodyFontSize,
),
],
],
),
@@ -72,7 +80,10 @@ class AppointmentTile extends StatelessWidget {
borderRadius: _radius,
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(width: 2, color: Colors.red.withAlpha(200)),
border: Border.all(
width: 2,
color: Colors.red.withAlpha(200),
),
borderRadius: _radius,
),
child: CustomPaint(painter: CrossPainter()),
@@ -114,7 +125,10 @@ class _AdaptiveTitle extends StatelessWidget {
builder: (context, constraints) {
// Probe at the minimum size: if even that overflows, we have to ellipsize.
final probe = TextPainter(
text: TextSpan(text: text, style: baseStyle.copyWith(fontSize: minFontSize)),
text: TextSpan(
text: text,
style: baseStyle.copyWith(fontSize: minFontSize),
),
textDirection: TextDirection.ltr,
maxLines: 1,
textScaler: textScaler,
@@ -131,12 +145,7 @@ class _AdaptiveTitle extends StatelessWidget {
return FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
text,
style: baseStyle,
maxLines: 1,
softWrap: false,
),
child: Text(text, style: baseStyle, maxLines: 1, softWrap: false),
);
},
);
@@ -187,24 +196,17 @@ class _ScaledLine extends StatelessWidget {
final String text;
final double fontSize;
const _ScaledLine({
required this.text,
required this.fontSize,
});
const _ScaledLine({required this.text, required this.fontSize});
@override
Widget build(BuildContext context) => FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
text,
style: TextStyle(
color: Colors.white,
fontSize: fontSize,
height: 1.1,
),
maxLines: 1,
softWrap: false,
),
);
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
text,
style: TextStyle(color: Colors.white, fontSize: fontSize, height: 1.1),
maxLines: 1,
softWrap: false,
),
);
}
@@ -14,17 +14,17 @@ class _DayHeaderStrip extends StatelessWidget {
@override
Widget build(BuildContext context) => Row(
children: [
SizedBox(width: rulerWidth),
for (var d = 0; d < 5; d++)
Expanded(
child: _DayHeaderCell(
date: weekStart.add(Duration(days: d)),
today: today,
),
),
],
);
children: [
SizedBox(width: rulerWidth),
for (var d = 0; d < 5; d++)
Expanded(
child: _DayHeaderCell(
date: weekStart.add(Duration(days: d)),
today: today,
),
),
],
);
}
class _DayHeaderCell extends StatelessWidget {
@@ -37,7 +37,10 @@ class _DayHeaderCell extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isToday = date.isSameDay(today);
final dayName = DateFormat('EE', Localizations.localeOf(context).toString()).format(date).toUpperCase();
final dayName = DateFormat(
'EE',
Localizations.localeOf(context).toString(),
).format(date).toUpperCase();
final accent = theme.colorScheme.primary;
final onAccent = theme.colorScheme.onPrimary;
@@ -18,20 +18,30 @@ class _OutsideHoursStrip extends StatelessWidget {
@override
Widget build(BuildContext context) {
final outside = partitionAppointmentsForWeek(appointments, weekStart).outside;
final outside = partitionAppointmentsForWeek(
appointments,
weekStart,
).outside;
if (outside.every((day) => day.isEmpty)) return const SizedBox.shrink();
final theme = Theme.of(context);
final maxChipsPerDay = outside
.map((day) => day.length > kOutsideChipsMaxVisible ? kOutsideChipsMaxVisible : day.length)
.map(
(day) => day.length > kOutsideChipsMaxVisible
? kOutsideChipsMaxVisible
: day.length,
)
.fold<int>(0, (m, c) => c > m ? c : m);
final stripHeight = kOutsideStripVerticalPadding * 2 +
final stripHeight =
kOutsideStripVerticalPadding * 2 +
maxChipsPerDay * kOutsideChipHeight +
(maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * kOutsideChipSpacing : 0);
return Container(
color: theme.colorScheme.surfaceContainerLowest,
padding: const EdgeInsets.symmetric(vertical: kOutsideStripVerticalPadding),
padding: const EdgeInsets.symmetric(
vertical: kOutsideStripVerticalPadding,
),
child: SizedBox(
height: stripHeight - kOutsideStripVerticalPadding * 2,
child: Row(
@@ -72,27 +82,29 @@ class _OutsideDayColumn extends StatelessWidget {
for (var i = 0; i < hidden.length; i++) {
if (i > 0) tiles.add(const Divider(height: 1));
final apt = hidden[i];
tiles.add(ListTile(
leading: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: apt.color,
borderRadius: BorderRadius.circular(3),
tiles.add(
ListTile(
leading: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: apt.color,
borderRadius: BorderRadius.circular(3),
),
),
title: Text(
apt.subject,
style: isCrossedOut(apt)
? const TextStyle(decoration: TextDecoration.lineThrough)
: null,
),
subtitle: Text(_subtitleFor(apt)),
onTap: () {
Navigator.of(sheetCtx).pop();
onAppointmentTap(apt);
},
),
title: Text(
apt.subject,
style: isCrossedOut(apt)
? const TextStyle(decoration: TextDecoration.lineThrough)
: null,
),
subtitle: Text(_subtitleFor(apt)),
onTap: () {
Navigator.of(sheetCtx).pop();
onAppointmentTap(apt);
},
));
);
}
return tiles;
},
@@ -34,11 +34,7 @@ class _WeekGrid extends StatelessWidget {
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_PeriodRuler(
schedule: schedule,
layout: layout,
width: rulerWidth,
),
_PeriodRuler(schedule: schedule, layout: layout, width: rulerWidth),
for (var d = 0; d < 5; d++)
Expanded(
child: _DayColumn(
@@ -112,7 +108,11 @@ class _PeriodLabel extends StatelessWidget {
),
),
alignment: Alignment.center,
child: Icon(Icons.coffee_outlined, size: 12, color: secondaryTextColor.withAlpha(180)),
child: Icon(
Icons.coffee_outlined,
size: 12,
color: secondaryTextColor.withAlpha(180),
),
);
}
@@ -207,27 +207,49 @@ class _DayColumn extends StatelessWidget {
required this.onCreateEvent,
});
bool _overlapsExistingAppointment(DateTime start, DateTime end, List<Appointment> dayAppts) {
bool _overlapsExistingAppointment(
DateTime start,
DateTime end,
List<Appointment> dayAppts,
) {
for (final a in dayAppts) {
if (a.endTime.isAfter(start) && a.startTime.isBefore(end)) return true;
}
return false;
}
void _handleLongPress(LongPressStartDetails details, List<Appointment> dayAppts) {
void _handleLongPress(
LongPressStartDetails details,
List<Appointment> dayAppts,
) {
if (onCreateEvent == null) return;
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 = 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,
);
if (_overlapsExistingAppointment(start, end, dayAppts)) return;
HapticFeedback.mediumImpact();
onCreateEvent!(start, end);
}
void _showOverflowSheet(BuildContext context, List<Appointment> appointments) {
void _showOverflowSheet(
BuildContext context,
List<Appointment> appointments,
) {
final sorted = [...appointments]
..sort((a, b) => a.startTime.compareTo(b.startTime));
showDetailsBottomSheet(
@@ -237,27 +259,29 @@ class _DayColumn extends StatelessWidget {
for (var i = 0; i < sorted.length; i++) {
if (i > 0) tiles.add(const Divider(height: 1));
final apt = sorted[i];
tiles.add(ListTile(
leading: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: apt.color,
borderRadius: BorderRadius.circular(3),
tiles.add(
ListTile(
leading: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: apt.color,
borderRadius: BorderRadius.circular(3),
),
),
title: Text(
apt.subject,
style: isCrossedOut(apt)
? const TextStyle(decoration: TextDecoration.lineThrough)
: null,
),
subtitle: Text(_overflowSubtitle(apt)),
onTap: () {
Navigator.of(sheetContext).pop();
onAppointmentTap(apt);
},
),
title: Text(
apt.subject,
style: isCrossedOut(apt)
? const TextStyle(decoration: TextDecoration.lineThrough)
: null,
),
subtitle: Text(_overflowSubtitle(apt)),
onTap: () {
Navigator.of(sheetContext).pop();
onAppointmentTap(apt);
},
));
);
}
return tiles;
},
@@ -288,46 +312,53 @@ class _DayColumn extends StatelessWidget {
behavior: HitTestBehavior.translucent,
onLongPressStart: (details) => _handleLongPress(details, dayAppointments),
child: DecoratedBox(
decoration: BoxDecoration(
color: isToday ? theme.colorScheme.primary.withAlpha(14) : null,
border: Border(left: BorderSide(color: theme.dividerColor.withAlpha(90), width: 0.5)),
),
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
return Stack(
clipBehavior: Clip.none,
children: [
for (final period in schedule.periods)
Positioned(
top: layout.topOf(period),
left: 0,
right: 0,
child: Container(
height: 0.5,
color: theme.dividerColor.withAlpha(60),
decoration: BoxDecoration(
color: isToday ? theme.colorScheme.primary.withAlpha(14) : null,
border: Border(
left: BorderSide(
color: theme.dividerColor.withAlpha(90),
width: 0.5,
),
),
),
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
return Stack(
clipBehavior: Clip.none,
children: [
for (final period in schedule.periods)
Positioned(
top: layout.topOf(period),
left: 0,
right: 0,
child: Container(
height: 0.5,
color: theme.dividerColor.withAlpha(60),
),
),
),
for (final region in dayRegions)
Positioned(
top: layout.yOfDateTime(region.start),
height: (layout.yOfDateTime(region.end) -
layout.yOfDateTime(region.start))
.clamp(0, double.infinity),
left: 0,
right: 0,
child: TimeRegionTile(region: region.region),
),
for (final cell in laidOut)
Positioned(
top: layout.yOfDateTime(cell.startTime),
height: (layout.yOfDateTime(cell.endTime) -
layout.yOfDateTime(cell.startTime))
.clamp(0, double.infinity),
left: cell.lane * width / cell.laneCount,
width: width / cell.laneCount,
child: switch (cell) {
LaidOutAppointment(:final appointment) => GestureDetector(
for (final region in dayRegions)
Positioned(
top: layout.yOfDateTime(region.start),
height:
(layout.yOfDateTime(region.end) -
layout.yOfDateTime(region.start))
.clamp(0, double.infinity),
left: 0,
right: 0,
child: TimeRegionTile(region: region.region),
),
for (final cell in laidOut)
Positioned(
top: layout.yOfDateTime(cell.startTime),
height:
(layout.yOfDateTime(cell.endTime) -
layout.yOfDateTime(cell.startTime))
.clamp(0, double.infinity),
left: cell.lane * width / cell.laneCount,
width: width / cell.laneCount,
child: switch (cell) {
LaidOutAppointment(:final appointment) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onAppointmentTap(appointment),
child: AppointmentTile(
@@ -335,25 +366,27 @@ class _DayColumn extends StatelessWidget {
crossedOut: isCrossedOut(appointment),
),
),
LaidOutOverflow(:final appointments) => GestureDetector(
LaidOutOverflow(:final appointments) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () =>
_showOverflowSheet(context, appointments),
onTap: () => _showOverflowSheet(context, appointments),
child: _OverflowTile(count: appointments.length),
),
},
),
if (isToday)
ValueListenableBuilder<DateTime>(
valueListenable: nowNotifier,
builder: (_, now, child) =>
_CurrentTimeMarker(now: now, layout: layout, theme: theme),
),
],
);
},
},
),
if (isToday)
ValueListenableBuilder<DateTime>(
valueListenable: nowNotifier,
builder: (_, now, child) => _CurrentTimeMarker(
now: now,
layout: layout,
theme: theme,
),
),
],
);
},
),
),
),
);
}
}
@@ -376,8 +409,7 @@ class _CurrentTimeMarker extends StatelessWidget {
final tMin = now.hour * 60 + now.minute;
final firstStart =
periods.first.start.hour * 60 + periods.first.start.minute;
final lastEnd =
periods.last.end.hour * 60 + periods.last.end.minute;
final lastEnd = periods.last.end.hour * 60 + periods.last.end.minute;
if (tMin < firstStart || tMin > lastEnd) return const SizedBox.shrink();
final y = layout.yOfDateTime(now);
@@ -392,10 +424,7 @@ class _CurrentTimeMarker extends StatelessWidget {
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
height: 2,
color: theme.colorScheme.primary,
),
Container(height: 2, color: theme.colorScheme.primary),
Positioned(
top: -3,
left: -4,
@@ -456,7 +485,10 @@ class _OverflowTile extends StatelessWidget {
child: FittedBox(
fit: BoxFit.scaleDown,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
@@ -72,7 +72,8 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
_firstMonday = _mondayOf(widget.minDate);
final lastMonday = _mondayOf(widget.maxDate);
_totalWeeks = lastMonday.difference(_firstMonday).inDays ~/ 7 + 1;
_currentWeekIndex = _mondayOf(widget.initialDate).difference(_firstMonday).inDays ~/ 7;
_currentWeekIndex =
_mondayOf(widget.initialDate).difference(_firstMonday).inDays ~/ 7;
_pageController = PageController(initialPage: _currentWeekIndex);
_nowNotifier = ValueNotifier<DateTime>(DateTime.now());
@@ -113,7 +114,9 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final visibleWeekStart = _firstMonday.add(Duration(days: _currentWeekIndex * 7));
final visibleWeekStart = _firstMonday.add(
Duration(days: _currentWeekIndex * 7),
);
return Column(
children: [
@@ -168,13 +171,13 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
child: LayoutBuilder(
builder: (context, constraints) {
final periods = widget.schedule.periods;
final lessonCount =
periods.where((p) => !p.isBreak).length;
final lessonCount = periods.where((p) => !p.isBreak).length;
final breakCount = periods.length - lessonCount;
final available =
constraints.maxHeight - breakCount * kBreakBlockHeight;
final fitLessonH =
lessonCount > 0 ? available / lessonCount : kLessonBlockMinHeight;
final fitLessonH = lessonCount > 0
? available / lessonCount
: kLessonBlockMinHeight;
final lessonH = fitLessonH < kLessonBlockMinHeight
? kLessonBlockMinHeight
: fitLessonH;
@@ -194,11 +197,18 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
itemCount: _totalWeeks,
onPageChanged: (index) {
setState(() => _currentWeekIndex = index);
final weekStart = _firstMonday.add(Duration(days: index * 7));
widget.onWeekChanged(weekStart, weekStart.add(const Duration(days: 4)));
final weekStart = _firstMonday.add(
Duration(days: index * 7),
);
widget.onWeekChanged(
weekStart,
weekStart.add(const Duration(days: 4)),
);
},
itemBuilder: (_, weekIndex) {
final weekStart = _firstMonday.add(Duration(days: weekIndex * 7));
final weekStart = _firstMonday.add(
Duration(days: weekIndex * 7),
);
return _WeekGrid(
weekStart: weekStart,
schedule: widget.schedule,
@@ -22,45 +22,61 @@ class SpecialRegionsBuilder {
});
List<TimeRegion> build() {
final lastMonday = DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.monday);
final lastMonday = DateTime.now()
.subtract(const Duration(days: 14))
.nextWeekday(DateTime.monday);
final holidayRegions = _buildHolidayRegions().toList();
bool isInHoliday(DateTime time) => holidayRegions.any((region) => region.startTime.isSameDay(time));
bool isInHoliday(DateTime time) =>
holidayRegions.any((region) => region.startTime.isSameDay(time));
final breakRegions = schedule.periods.where((p) => p.isBreak).map((p) {
final start = lastMonday.copyWith(hour: p.start.hour, minute: p.start.minute);
return _breakRegion(start, p.duration);
}).where((region) => !isInHoliday(region.startTime));
final breakRegions = schedule.periods
.where((p) => p.isBreak)
.map((p) {
final start = lastMonday.copyWith(
hour: p.start.hour,
minute: p.start.minute,
);
return _breakRegion(start, p.duration);
})
.where((region) => !isInHoliday(region.startTime));
return [
...holidayRegions,
...breakRegions,
];
return [...holidayRegions, ...breakRegions];
}
Iterable<TimeRegion> _buildHolidayRegions() => holidays.result.expand((holiday) {
final startDay = WebuntisTime.parse(holiday.startDate, 0);
final dayCount = WebuntisTime.parse(holiday.endDate, 0).difference(startDay).inDays;
final days = List<DateTime>.generate(dayCount, (i) => startDay.add(Duration(days: i)));
final gridStartHour = kCalendarStartHour.floor();
final gridStartMinute = ((kCalendarStartHour - gridStartHour) * 60).round();
final gridEndHour = kCalendarEndHour.floor();
final gridEndMinute = ((kCalendarEndHour - gridEndHour) * 60).round();
return days.map((day) => TimeRegion(
startTime: day.copyWith(hour: gridStartHour, minute: gridStartMinute),
endTime: day.copyWith(hour: gridEndHour, minute: gridEndMinute),
text: '$kTimeRegionHolidayPrefix${holiday.name}',
color: disabledColor.withAlpha(50),
iconData: Icons.holiday_village_outlined,
));
});
Iterable<TimeRegion> _buildHolidayRegions() => holidays.result.expand((
holiday,
) {
final startDay = WebuntisTime.parse(holiday.startDate, 0);
final dayCount = WebuntisTime.parse(
holiday.endDate,
0,
).difference(startDay).inDays;
final days = List<DateTime>.generate(
dayCount,
(i) => startDay.add(Duration(days: i)),
);
final gridStartHour = kCalendarStartHour.floor();
final gridStartMinute = ((kCalendarStartHour - gridStartHour) * 60).round();
final gridEndHour = kCalendarEndHour.floor();
final gridEndMinute = ((kCalendarEndHour - gridEndHour) * 60).round();
return days.map(
(day) => TimeRegion(
startTime: day.copyWith(hour: gridStartHour, minute: gridStartMinute),
endTime: day.copyWith(hour: gridEndHour, minute: gridEndMinute),
text: '$kTimeRegionHolidayPrefix${holiday.name}',
color: disabledColor.withAlpha(50),
iconData: Icons.holiday_village_outlined,
),
);
});
TimeRegion _breakRegion(DateTime start, Duration duration) => TimeRegion(
startTime: start,
endTime: start.add(duration),
recurrenceRule: 'FREQ=DAILY;INTERVAL=1',
text: kTimeRegionCenterIcon,
color: colorScheme.primary.withAlpha(50),
iconData: Icons.restaurant,
);
startTime: start,
endTime: start.add(duration),
recurrenceRule: 'FREQ=DAILY;INTERVAL=1',
text: kTimeRegionCenterIcon,
color: colorScheme.primary.withAlpha(50),
iconData: Icons.restaurant,
);
}
@@ -18,7 +18,11 @@ class TimeRegionTile extends StatelessWidget {
return Container(
color: color,
alignment: Alignment.center,
child: Icon(region.iconData, size: 17, color: Theme.of(context).colorScheme.primary),
child: Icon(
region.iconData,
size: 17,
color: Theme.of(context).colorScheme.primary,
),
);
}