dart format
This commit is contained in:
+27
-24
@@ -38,34 +38,37 @@ class _LoginState extends State<Login> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
backgroundColor: _marianumRed,
|
||||
body: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: constraints.maxHeight,
|
||||
maxWidth: 420,
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Column(
|
||||
children: [
|
||||
const LoginHeader(),
|
||||
const SizedBox(height: 28),
|
||||
LoginCard(controller: _controller, onSuccess: _onLoginSuccess),
|
||||
const SizedBox(height: 18),
|
||||
const LoginDisclaimer(),
|
||||
const Spacer(),
|
||||
const LoginFooter(),
|
||||
],
|
||||
backgroundColor: _marianumRed,
|
||||
body: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: constraints.maxHeight,
|
||||
maxWidth: 420,
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Column(
|
||||
children: [
|
||||
const LoginHeader(),
|
||||
const SizedBox(height: 28),
|
||||
LoginCard(
|
||||
controller: _controller,
|
||||
onSuccess: _onLoginSuccess,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
const LoginDisclaimer(),
|
||||
const Spacer(),
|
||||
const LoginFooter(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,37 +5,37 @@ class LoginHeader extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
children: [
|
||||
const SizedBox(height: 40),
|
||||
Image.asset(
|
||||
'assets/logo/icon.png',
|
||||
height: 110,
|
||||
fit: BoxFit.contain,
|
||||
gaplessPlayback: true,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'Marianum Fulda',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Stundenplan, Talk & Dateien an einem Ort.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.85),
|
||||
fontSize: 14,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
children: [
|
||||
const SizedBox(height: 40),
|
||||
Image.asset(
|
||||
'assets/logo/icon.png',
|
||||
height: 110,
|
||||
fit: BoxFit.contain,
|
||||
gaplessPlayback: true,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'Marianum Fulda',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Stundenplan, Talk & Dateien an einem Ort.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.85),
|
||||
fontSize: 14,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class LoginDisclaimer extends StatelessWidget {
|
||||
@@ -43,17 +43,17 @@ class LoginDisclaimer extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
'Inoffizieller Nextcloud & Webuntis Client. Wird nicht vom Marianum betrieben. Keine Gewähr für Vollständigkeit, Richtigkeit und Aktualität.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.75),
|
||||
fontSize: 11,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
);
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
'Inoffizieller Nextcloud & Webuntis Client. Wird nicht vom Marianum betrieben. Keine Gewähr für Vollständigkeit, Richtigkeit und Aktualität.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.75),
|
||||
fontSize: 11,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class LoginFooter extends StatelessWidget {
|
||||
@@ -61,15 +61,15 @@ class LoginFooter extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.only(top: 16, bottom: 8),
|
||||
child: Text(
|
||||
'Marianum Fulda. Die persönliche Schule.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
fontSize: 12,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
);
|
||||
padding: const EdgeInsets.only(top: 16, bottom: 8),
|
||||
child: Text(
|
||||
'Marianum Fulda. Die persönliche Schule.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
fontSize: 12,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,11 @@ class LoginCard extends StatefulWidget {
|
||||
final LoginController controller;
|
||||
final VoidCallback onSuccess;
|
||||
|
||||
const LoginCard({required this.controller, required this.onSuccess, super.key});
|
||||
const LoginCard({
|
||||
required this.controller,
|
||||
required this.onSuccess,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LoginCard> createState() => _LoginCardState();
|
||||
@@ -59,7 +63,9 @@ class _LoginCardState extends State<LoginCard> {
|
||||
labelText: label,
|
||||
prefixIcon: Icon(icon),
|
||||
filled: true,
|
||||
fillColor: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
|
||||
fillColor: theme.colorScheme.surfaceContainerHighest.withValues(
|
||||
alpha: 0.4,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
@@ -92,7 +98,9 @@ class _LoginCardState extends State<LoginCard> {
|
||||
children: [
|
||||
Text(
|
||||
'Anmelden',
|
||||
style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
@@ -109,7 +117,11 @@ class _LoginCardState extends State<LoginCard> {
|
||||
autocorrect: false,
|
||||
textInputAction: TextInputAction.next,
|
||||
onFieldSubmitted: (_) => _passwordFocus.requestFocus(),
|
||||
decoration: _decoration(theme, 'Nutzername', Icons.person_outline),
|
||||
decoration: _decoration(
|
||||
theme,
|
||||
'Nutzername',
|
||||
Icons.person_outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
@@ -136,14 +148,22 @@ class _LoginCardState extends State<LoginCard> {
|
||||
child: FilledButton(
|
||||
onPressed: loading ? null : _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
child: loading
|
||||
? const SizedBox(
|
||||
height: 22,
|
||||
width: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text('Anmelden'),
|
||||
),
|
||||
|
||||
@@ -9,7 +9,11 @@ class LoginErrorBanner extends StatelessWidget {
|
||||
final String? message;
|
||||
final String? details;
|
||||
|
||||
const LoginErrorBanner({required this.message, required this.details, super.key});
|
||||
const LoginErrorBanner({
|
||||
required this.message,
|
||||
required this.details,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -26,14 +30,26 @@ class LoginErrorBanner extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: details != null
|
||||
? () => InfoDialog.show(context, details!, copyable: true, title: 'Fehlerdetails')
|
||||
? () => InfoDialog.show(
|
||||
context,
|
||||
details!,
|
||||
copyable: true,
|
||||
title: 'Fehlerdetails',
|
||||
)
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 20, color: theme.colorScheme.onErrorContainer),
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 20,
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -50,7 +66,8 @@ class LoginErrorBanner extends StatelessWidget {
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
size: 20,
|
||||
color: theme.colorScheme.onErrorContainer.withValues(alpha: 0.7),
|
||||
color: theme.colorScheme.onErrorContainer
|
||||
.withValues(alpha: 0.7),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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ß!',
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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!,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]);
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user