claude refactor
This commit is contained in:
+157
-159
@@ -1,32 +1,22 @@
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:loader_overlay/loader_overlay.dart';
|
||||
import 'package:nextcloud/nextcloud.dart';
|
||||
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../api/marianumcloud/webdav/queries/listFiles/cacheableFile.dart';
|
||||
import '../../../api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart';
|
||||
import '../../../api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart';
|
||||
import '../../../api/marianumcloud/webdav/webdavApi.dart';
|
||||
import '../../../model/files/filesProps.dart';
|
||||
import '../../../storage/base/settingsProvider.dart';
|
||||
import '../../../widget/loadingSpinner.dart';
|
||||
import '../../../widget/placeholderView.dart';
|
||||
import '../../../state/app/infrastructure/loadableState/loadable_state.dart';
|
||||
import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart';
|
||||
import '../../../state/app/infrastructure/utilityWidgets/bloc_module.dart';
|
||||
import '../../../state/app/modules/files/bloc/files_bloc.dart';
|
||||
import '../../../state/app/modules/files/bloc/files_state.dart';
|
||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../widget/filePick.dart';
|
||||
import '../../../widget/placeholderView.dart';
|
||||
import 'fileElement.dart';
|
||||
import 'filesUploadDialog.dart';
|
||||
|
||||
class Files extends StatefulWidget {
|
||||
final List<String> path;
|
||||
Files({List<String>? path, super.key}) : path = path ?? [];
|
||||
|
||||
@override
|
||||
State<Files> createState() => _FilesState();
|
||||
}
|
||||
|
||||
class BetterSortOption {
|
||||
String displayName;
|
||||
int Function(CacheableFile, CacheableFile) compare;
|
||||
@@ -35,111 +25,107 @@ class BetterSortOption {
|
||||
BetterSortOption({required this.displayName, required this.icon, required this.compare});
|
||||
}
|
||||
|
||||
enum SortOption {
|
||||
name,
|
||||
date,
|
||||
size
|
||||
}
|
||||
enum SortOption { name, date, size }
|
||||
|
||||
class SortOptions {
|
||||
static Map<SortOption, BetterSortOption> options = {
|
||||
SortOption.name: BetterSortOption(
|
||||
displayName: 'Name',
|
||||
icon: Icons.sort_by_alpha_outlined,
|
||||
compare: (CacheableFile a, CacheableFile b) => a.name.compareTo(b.name)
|
||||
compare: (a, b) => a.name.compareTo(b.name),
|
||||
),
|
||||
SortOption.date: BetterSortOption(
|
||||
displayName: 'Datum',
|
||||
icon: Icons.history_outlined,
|
||||
compare: (CacheableFile a, CacheableFile b) => a.modifiedAt!.compareTo(b.modifiedAt!)
|
||||
compare: (a, b) => a.modifiedAt!.compareTo(b.modifiedAt!),
|
||||
),
|
||||
SortOption.size: BetterSortOption(
|
||||
displayName: 'Größe',
|
||||
icon: Icons.sd_card_outlined,
|
||||
compare: (CacheableFile a, CacheableFile b) {
|
||||
if(a.isDirectory || b.isDirectory) return a.isDirectory ? 1 : 0;
|
||||
if(a.size == null) return 0;
|
||||
if(b.size == null) return 1;
|
||||
compare: (a, b) {
|
||||
if (a.isDirectory || b.isDirectory) return a.isDirectory ? 1 : 0;
|
||||
if (a.size == null) return 0;
|
||||
if (b.size == null) return 1;
|
||||
return a.size!.compareTo(b.size!);
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
static BetterSortOption getOption(SortOption option) => options[option]!;
|
||||
}
|
||||
|
||||
class _FilesState extends State<Files> {
|
||||
FilesProps props = FilesProps();
|
||||
ListFilesResponse? data;
|
||||
class Files extends StatelessWidget {
|
||||
final List<String> path;
|
||||
|
||||
late SettingsProvider settings = Provider.of<SettingsProvider>(context, listen: false);
|
||||
Files({List<String>? path, super.key}) : path = path ?? [];
|
||||
|
||||
SortOption currentSort = SortOption.name;
|
||||
bool currentSortDirection = true;
|
||||
@override
|
||||
Widget build(BuildContext context) => BlocModule<FilesBloc, LoadableState<FilesState>>(
|
||||
create: (_) => FilesBloc(initialPath: path),
|
||||
child: (context, _, _) => _FilesView(path: path),
|
||||
);
|
||||
}
|
||||
|
||||
class _FilesView extends StatefulWidget {
|
||||
final List<String> path;
|
||||
const _FilesView({required this.path});
|
||||
|
||||
@override
|
||||
State<_FilesView> createState() => _FilesViewState();
|
||||
}
|
||||
|
||||
class _FilesViewState extends State<_FilesView> {
|
||||
late final SettingsCubit settings;
|
||||
late SortOption currentSort;
|
||||
late bool currentSortDirection;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
settings = context.read<SettingsCubit>();
|
||||
currentSort = settings.val().fileSettings.sortBy;
|
||||
currentSortDirection = settings.val().fileSettings.ascending;
|
||||
_query();
|
||||
}
|
||||
|
||||
void _query() {
|
||||
ListFilesCache(
|
||||
path: widget.path.isEmpty ? '/' : widget.path.join('/'),
|
||||
onUpdate: (ListFilesResponse d) {
|
||||
d.files.removeWhere((element) => element.name.isEmpty || element.name == widget.path.lastOrNull());
|
||||
setState(() {
|
||||
data = d;
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> mediaUpload(List<String>? paths) async {
|
||||
if(paths == null) return;
|
||||
|
||||
if (paths == null) return;
|
||||
final bloc = context.read<FilesBloc>();
|
||||
pushScreen(
|
||||
context,
|
||||
withNavBar: false,
|
||||
screen: FilesUploadDialog(filePaths: paths, remotePath: widget.path.join('/'), onUploadFinished: (uploadedFilePaths) => _query()),
|
||||
screen: FilesUploadDialog(
|
||||
filePaths: paths,
|
||||
remotePath: widget.path.join('/'),
|
||||
onUploadFinished: (_) => bloc.refresh(),
|
||||
),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var files = data?.sortBy(
|
||||
sortOption: currentSort,
|
||||
foldersToTop: Provider.of<SettingsProvider>(context).val().fileSettings.sortFoldersToTop,
|
||||
reversed: currentSortDirection
|
||||
) ?? List<CacheableFile>.empty();
|
||||
|
||||
final bloc = context.read<FilesBloc>();
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.path.isNotEmpty ? widget.path.last : 'Dateien'),
|
||||
actions: [
|
||||
// IconButton(
|
||||
// icon: const Icon(Icons.search),
|
||||
// onPressed: () => {
|
||||
// // TODO implement search
|
||||
// },
|
||||
// ),
|
||||
PopupMenuButton<bool>(
|
||||
icon: Icon(currentSortDirection ? Icons.text_rotate_up : Icons.text_rotation_down),
|
||||
itemBuilder: (context) => [true, false].map((e) => PopupMenuItem<bool>(
|
||||
value: e,
|
||||
enabled: e != currentSortDirection,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(e ? Icons.text_rotate_up : Icons.text_rotation_down, color: Theme.of(context).colorScheme.onSurface),
|
||||
const SizedBox(width: 15),
|
||||
Text(e ? 'Aufsteigend' : 'Absteigend')
|
||||
],
|
||||
)
|
||||
)).toList(),
|
||||
itemBuilder: (context) => [true, false]
|
||||
.map((e) => PopupMenuItem<bool>(
|
||||
value: e,
|
||||
enabled: e != currentSortDirection,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
e ? Icons.text_rotate_up : Icons.text_rotation_down,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
Text(e ? 'Aufsteigend' : 'Absteigend'),
|
||||
],
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
onSelected: (e) {
|
||||
setState(() {
|
||||
currentSortDirection = e;
|
||||
@@ -149,17 +135,19 @@ class _FilesState extends State<Files> {
|
||||
),
|
||||
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.of(context).colorScheme.onSurface),
|
||||
const SizedBox(width: 15),
|
||||
Text(SortOptions.getOption(key).displayName),
|
||||
],
|
||||
)
|
||||
)).toList(),
|
||||
itemBuilder: (context) => SortOptions.options.keys
|
||||
.map((key) => PopupMenuItem<SortOption>(
|
||||
value: key,
|
||||
enabled: key != currentSort,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(SortOptions.getOption(key).icon, color: Theme.of(context).colorScheme.onSurface),
|
||||
const SizedBox(width: 15),
|
||||
Text(SortOptions.getOption(key).displayName),
|
||||
],
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
onSelected: (e) {
|
||||
setState(() {
|
||||
currentSort = e;
|
||||
@@ -172,81 +160,91 @@ class _FilesState extends State<Files> {
|
||||
floatingActionButton: FloatingActionButton(
|
||||
heroTag: 'uploadFile',
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
onPressed: () {
|
||||
showDialog(context: context, builder: (context) => SimpleDialog(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.create_new_folder_outlined),
|
||||
title: const Text('Ordner erstellen'),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
showDialog(context: context, builder: (context) {
|
||||
var inputController = TextEditingController();
|
||||
return AlertDialog(
|
||||
title: const Text('Neuer Ordner'),
|
||||
content: TextField(
|
||||
controller: inputController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Name',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
}, child: const Text('Abbrechen')),
|
||||
TextButton(onPressed: () {
|
||||
WebdavApi.webdav.then((webdav) {
|
||||
webdav.mkcol(PathUri.parse("${widget.path.join("/")}/${inputController.text}")).then((value) => _query());
|
||||
});
|
||||
Navigator.of(context).pop();
|
||||
}, child: const Text('Ordner erstellen')),
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.upload_file),
|
||||
title: const Text('Aus Dateien hochladen'),
|
||||
onTap: () {
|
||||
FilePick.documentPick().then(mediaUpload);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
Visibility(
|
||||
visible: !Platform.isIOS,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.add_a_photo_outlined),
|
||||
title: const Text('Aus Gallerie hochladen'),
|
||||
onTap: () {
|
||||
FilePick.multipleGalleryPick().then((value) {
|
||||
if(value != null) mediaUpload(value.map((e) => e.path).toList());
|
||||
});
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
));
|
||||
},
|
||||
onPressed: () => _showAddDialog(context, bloc),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: data == null ? const LoadingSpinner() : data!.files.isEmpty ? const PlaceholderView(icon: Icons.folder_off_rounded, text: 'Der Ordner ist leer') : LoaderOverlay(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () {
|
||||
_query();
|
||||
return Future.delayed(const Duration(seconds: 3));
|
||||
body: LoadableStateConsumer<FilesBloc, FilesState>(
|
||||
child: (state, _) {
|
||||
final listing = state.listing;
|
||||
if (listing == null) return const SizedBox.shrink();
|
||||
if (listing.files.isEmpty) {
|
||||
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,
|
||||
reversed: currentSortDirection,
|
||||
);
|
||||
return LoaderOverlay(
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: files.length,
|
||||
itemBuilder: (context, index) => FileElement(files[index], widget.path, bloc.refresh),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAddDialog(BuildContext context, FilesBloc bloc) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogCtx) => SimpleDialog(children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.create_new_folder_outlined),
|
||||
title: const Text('Ordner erstellen'),
|
||||
onTap: () {
|
||||
Navigator.of(dialogCtx).pop();
|
||||
_showCreateFolderDialog(context, bloc);
|
||||
},
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: files.length,
|
||||
itemBuilder: (context, index) {
|
||||
var file = files.toList()[index];
|
||||
return FileElement(file, widget.path, _query);
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.upload_file),
|
||||
title: const Text('Aus Dateien hochladen'),
|
||||
onTap: () {
|
||||
FilePick.documentPick().then(mediaUpload);
|
||||
Navigator.of(dialogCtx).pop();
|
||||
},
|
||||
),
|
||||
Visibility(
|
||||
visible: !Platform.isIOS,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.add_a_photo_outlined),
|
||||
title: const Text('Aus Gallerie hochladen'),
|
||||
onTap: () {
|
||||
FilePick.multipleGalleryPick().then((value) {
|
||||
if (value != null) mediaUpload(value.map((e) => e.path).toList());
|
||||
});
|
||||
Navigator.of(dialogCtx).pop();
|
||||
},
|
||||
),
|
||||
)
|
||||
)
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
void _showCreateFolderDialog(BuildContext context, FilesBloc bloc) {
|
||||
final inputController = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogCtx) => AlertDialog(
|
||||
title: const Text('Neuer Ordner'),
|
||||
content: TextField(
|
||||
controller: inputController,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('Abbrechen')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
bloc.createFolder(inputController.text);
|
||||
Navigator.of(dialogCtx).pop();
|
||||
},
|
||||
child: const Text('Ordner erstellen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,28 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showUploadError(String message) {
|
||||
setState(() {
|
||||
_isUploading = false;
|
||||
_overallProgressValue = 0.0;
|
||||
_infoText = '';
|
||||
});
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) => AlertDialog(
|
||||
title: const Text('Upload fehlgeschlagen'),
|
||||
contentPadding: const EdgeInsets.all(10),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Schließen', textAlign: TextAlign.center),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> uploadFiles({bool override = false}) async {
|
||||
setState(() {
|
||||
_isUploading = true;
|
||||
@@ -74,16 +96,24 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
}
|
||||
});
|
||||
|
||||
var webdavClient = await WebdavApi.webdav;
|
||||
final webdavClient = await WebdavApi.webdav;
|
||||
|
||||
if (!override) {
|
||||
var result = (await webdavClient.propfind(PathUri.parse(widget.remotePath))).responses;
|
||||
List<dynamic> result;
|
||||
try {
|
||||
result = (await webdavClient.propfind(PathUri.parse(widget.remotePath))).responses;
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_showUploadError('Verbindung fehlgeschlagen: $e');
|
||||
return;
|
||||
}
|
||||
var conflictingFiles = _uploadableFiles.where((file) {
|
||||
var fileName = file.fileName;
|
||||
return result.any((element) => Uri.decodeComponent(element.href!).endsWith('/$fileName'));
|
||||
}).toList();
|
||||
|
||||
if(conflictingFiles.isNotEmpty) {
|
||||
if (!mounted) return;
|
||||
bool replaceFiles = await showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
@@ -157,17 +187,24 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
_infoText = '${_uploadableFiles.indexOf(file) + 1}/${_uploadableFiles.length}';
|
||||
});
|
||||
|
||||
var uploadTask = await webdavClient.putFile(
|
||||
File(filePath),
|
||||
FileStat.statSync(filePath),
|
||||
PathUri.parse(fullRemotePath),
|
||||
onProgress: (progress) {
|
||||
setState(() {
|
||||
file._uploadProgress = progress;
|
||||
_overallProgressValue = ((progress + _uploadableFiles.indexOf(file)) / _uploadableFiles.length).toDouble();
|
||||
});
|
||||
},
|
||||
);
|
||||
final dynamic uploadTask;
|
||||
try {
|
||||
uploadTask = await webdavClient.putFile(
|
||||
File(filePath),
|
||||
FileStat.statSync(filePath),
|
||||
PathUri.parse(fullRemotePath),
|
||||
onProgress: (progress) {
|
||||
setState(() {
|
||||
file._uploadProgress = progress;
|
||||
_overallProgressValue = ((progress + _uploadableFiles.indexOf(file)) / _uploadableFiles.length).toDouble();
|
||||
});
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_showUploadError('Upload fehlgeschlagen für "$fileName": $e');
|
||||
return;
|
||||
}
|
||||
|
||||
if(uploadTask.statusCode < 200 || uploadTask.statusCode > 299) {
|
||||
setState(() {
|
||||
@@ -175,6 +212,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
_overallProgressValue = 0.0;
|
||||
_infoText = '';
|
||||
});
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
showHttpErrorCode(uploadTask.statusCode);
|
||||
} else {
|
||||
@@ -187,6 +225,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
_overallProgressValue = 0.0;
|
||||
_infoText = '';
|
||||
});
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
widget.onUploadFinished(uploadetFilePaths);
|
||||
}
|
||||
|
||||
@@ -6,13 +6,13 @@ import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:loader_overlay/loader_overlay.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:badges/badges.dart' as badges;
|
||||
|
||||
import '../../../../api/mhsl/server/feedback/addFeedback.dart';
|
||||
import '../../../../api/mhsl/server/feedback/addFeedbackParams.dart';
|
||||
import '../../../../model/accountData.dart';
|
||||
import '../../../../storage/base/settingsProvider.dart';
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../../widget/filePick.dart';
|
||||
import '../../../../widget/focusBehaviour.dart';
|
||||
import '../../../../widget/infoDialog.dart';
|
||||
@@ -113,7 +113,7 @@ class _FeedbackDialogState extends State<FeedbackDialog> {
|
||||
child: Visibility(
|
||||
visible: _error != null,
|
||||
child: Visibility(
|
||||
visible: Provider.of<SettingsProvider>(context, listen: false).val().devToolsEnabled,
|
||||
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)),
|
||||
),
|
||||
@@ -156,13 +156,16 @@ class _FeedbackDialogState extends State<FeedbackDialog> {
|
||||
appVersion: int.parse((await PackageInfo.fromPlatform()).buildNumber),
|
||||
)
|
||||
).run().then((value) {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
InfoDialog.show(context, 'Danke für dein Feedback!');
|
||||
context.loaderOverlay.hide();
|
||||
}).catchError((error, trace) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = error.toString();
|
||||
});
|
||||
if (!context.mounted) return;
|
||||
context.loaderOverlay.hide();
|
||||
});
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ class Roomplan extends StatelessWidget {
|
||||
imageProvider: Image.asset('assets/img/raumplan.jpg').image,
|
||||
minScale: 0.5,
|
||||
maxScale: 2.0,
|
||||
backgroundDecoration: BoxDecoration(color: Theme.of(context).colorScheme.background),
|
||||
backgroundDecoration: BoxDecoration(color: Theme.of(context).colorScheme.surface),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ class AppSharePlatformView extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var foregroundColor = Theme.of(context).colorScheme.onBackground;
|
||||
var foregroundColor = Theme.of(context).colorScheme.onSurface;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
|
||||
@@ -23,14 +23,14 @@ class SelectShareTypeDialog extends StatelessWidget {
|
||||
title: const Text('Per Link teilen'),
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: () {
|
||||
Share.share(
|
||||
sharePositionOrigin: SharePositionOrigin.get(context),
|
||||
subject: 'App Teilen',
|
||||
'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(context),
|
||||
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ß!',
|
||||
));
|
||||
},
|
||||
)
|
||||
],
|
||||
|
||||
@@ -3,12 +3,13 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:in_app_review/in_app_review.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../extensions/renderNotNull.dart';
|
||||
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
|
||||
|
||||
import '../../state/app/modules/app_modules.dart';
|
||||
import '../../storage/base/settingsProvider.dart';
|
||||
import '../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../storage/base/settings.dart' as model;
|
||||
import '../../widget/centeredLeading.dart';
|
||||
import '../../widget/infoDialog.dart';
|
||||
import '../settings/defaultSettings.dart';
|
||||
@@ -27,7 +28,9 @@ class _OverhangState extends State<Overhang> {
|
||||
bool editMode = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Consumer<SettingsProvider>(builder: (context, settings, child) => Scaffold(
|
||||
Widget build(BuildContext context) => BlocBuilder<SettingsCubit, model.Settings>(builder: (context, _) {
|
||||
final settings = context.read<SettingsCubit>();
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Mehr'),
|
||||
actions: [
|
||||
@@ -42,9 +45,11 @@ class _OverhangState extends State<Overhang> {
|
||||
],
|
||||
),
|
||||
body: editMode ? _sorting() : _overhang(),
|
||||
));
|
||||
);
|
||||
});
|
||||
|
||||
Widget _sorting() => Consumer<SettingsProvider>(builder: (context, settings, child) {
|
||||
Widget _sorting() => BlocBuilder<SettingsCubit, model.Settings>(builder: (context, _) {
|
||||
final settings = context.read<SettingsCubit>();
|
||||
void changeVisibility(Modules module) {
|
||||
var hidden = settings.val(write: true).modulesSettings.hiddenModules;
|
||||
hidden.contains(module) ? hidden.remove(module) : (hidden.length < 3 ? hidden.add(module) : null);
|
||||
@@ -107,8 +112,14 @@ class _OverhangState extends State<Overhang> {
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: () {
|
||||
InAppReview.instance.openStoreListing(appStoreId: '6458789560').then(
|
||||
(value) => InfoDialog.show(context, 'Vielen Dank!'),
|
||||
onError: (error) => InfoDialog.show(context, error.toString())
|
||||
(value) {
|
||||
if (!context.mounted) return;
|
||||
InfoDialog.show(context, 'Vielen Dank!');
|
||||
},
|
||||
onError: (error) {
|
||||
if (!context.mounted) return;
|
||||
InfoDialog.show(context, error.toString());
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,79 +1,85 @@
|
||||
|
||||
import 'dart:async';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_split_view/flutter_split_view.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../api/marianumcloud/talk/createRoom/createRoom.dart';
|
||||
import '../../../api/marianumcloud/talk/createRoom/createRoomParams.dart';
|
||||
import '../../../model/chatList/chatListProps.dart';
|
||||
import '../../../state/app/infrastructure/loadableState/loadable_state.dart';
|
||||
import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart';
|
||||
import '../../../state/app/infrastructure/utilityWidgets/bloc_module.dart';
|
||||
import '../../../state/app/modules/chatList/bloc/chat_list_bloc.dart';
|
||||
import '../../../state/app/modules/chatList/bloc/chat_list_state.dart';
|
||||
import '../../../notification/notifyUpdater.dart';
|
||||
import '../../../storage/base/settingsProvider.dart';
|
||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../widget/confirmDialog.dart';
|
||||
import '../../../widget/loadingSpinner.dart';
|
||||
import 'components/chatTile.dart';
|
||||
import 'components/splitViewPlaceholder.dart';
|
||||
import 'joinChat.dart';
|
||||
import 'searchChat.dart';
|
||||
|
||||
class ChatList extends StatefulWidget {
|
||||
class ChatList extends StatelessWidget {
|
||||
const ChatList({super.key});
|
||||
|
||||
@override
|
||||
State<ChatList> createState() => _ChatListState();
|
||||
Widget build(BuildContext context) => BlocModule<ChatListBloc, LoadableState<ChatListState>>(
|
||||
create: (_) => ChatListBloc(),
|
||||
child: (context, bloc, _) => const _ChatListView(),
|
||||
);
|
||||
}
|
||||
|
||||
class _ChatListState extends State<ChatList> {
|
||||
late SettingsProvider settings;
|
||||
class _ChatListView extends StatefulWidget {
|
||||
const _ChatListView();
|
||||
|
||||
@override
|
||||
State<_ChatListView> createState() => _ChatListViewState();
|
||||
}
|
||||
|
||||
class _ChatListViewState extends State<_ChatListView> {
|
||||
late final SettingsCubit _settings;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
settings = Provider.of<SettingsProvider>(context, listen: false);
|
||||
_settings = context.read<SettingsCubit>();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||
_query();
|
||||
|
||||
if(!settings.val().notificationSettings.enabled && !settings.val().notificationSettings.askUsageDismissed) {
|
||||
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.',
|
||||
confirmButton: 'Weiter',
|
||||
onConfirm: () {
|
||||
FirebaseMessaging.instance.requestPermission(
|
||||
provisional: false
|
||||
).then((value) {
|
||||
switch (value.authorizationStatus) {
|
||||
case AuthorizationStatus.authorized:
|
||||
NotifyUpdater.enableAfterDisclaimer(settings).asDialog(context);
|
||||
break;
|
||||
case AuthorizationStatus.denied:
|
||||
showDialog(context: context, builder: (context) => const AlertDialog(
|
||||
content: Text('Du kannst die Benachrichtigungen später jederzeit in den App-Einstellungen aktivieren.'),
|
||||
));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
},
|
||||
).asDialog(context);
|
||||
}
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _maybeAskForNotificationPermission());
|
||||
}
|
||||
|
||||
void _query({bool renew = false}) {
|
||||
Provider.of<ChatListProps>(context, listen: false).run(renew: renew);
|
||||
void _maybeAskForNotificationPermission() {
|
||||
final notificationSettings = _settings.val().notificationSettings;
|
||||
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.',
|
||||
confirmButton: 'Weiter',
|
||||
onConfirm: () {
|
||||
FirebaseMessaging.instance.requestPermission(provisional: false).then((value) {
|
||||
if (!mounted) return;
|
||||
switch (value.authorizationStatus) {
|
||||
case AuthorizationStatus.authorized:
|
||||
NotifyUpdater.enableAfterDisclaimer(_settings).asDialog(context);
|
||||
break;
|
||||
case AuthorizationStatus.denied:
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => const AlertDialog(
|
||||
content: Text('Du kannst die Benachrichtigungen später jederzeit in den App-Einstellungen aktivieren.'),
|
||||
),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
},
|
||||
).asDialog(context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ChatListProps? latestData;
|
||||
|
||||
final bloc = context.read<ChatListBloc>();
|
||||
return SplitView.material(
|
||||
placeholder: const SplitViewPlaceholder(),
|
||||
breakpoint: 1000,
|
||||
@@ -83,63 +89,50 @@ class _ChatListState extends State<ChatList> {
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () async {
|
||||
if(latestData == null) return;
|
||||
showSearch(context: context, delegate: SearchChat(latestData!.getRoomsResponse.data.toList()));
|
||||
onPressed: () {
|
||||
final rooms = bloc.state.data?.rooms;
|
||||
if (rooms == null) return;
|
||||
showSearch(context: context, delegate: SearchChat(rooms.data.toList()));
|
||||
},
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
heroTag: 'createChat',
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
onPressed: () async {
|
||||
onPressed: () {
|
||||
showSearch(context: context, delegate: JoinChat()).then((username) {
|
||||
if(username == null) return;
|
||||
|
||||
if (username == null || !context.mounted) return;
|
||||
ConfirmDialog(
|
||||
title: 'Chat starten',
|
||||
content: "Möchtest du einen Chat mit Nutzer '$username' starten?",
|
||||
confirmButton: 'Chat starten',
|
||||
onConfirm: () {
|
||||
CreateRoom(CreateRoomParams(
|
||||
roomType: 1,
|
||||
invite: username,
|
||||
)).run().then((value) {
|
||||
_query(renew: true);
|
||||
});
|
||||
bloc.createDirectChat(username);
|
||||
},
|
||||
).asDialog(context);
|
||||
});
|
||||
},
|
||||
child: const Icon(Icons.add_comment_outlined),
|
||||
),
|
||||
body: Consumer<ChatListProps>(
|
||||
builder: (context, data, child) {
|
||||
body: LoadableStateConsumer<ChatListBloc, ChatListState>(
|
||||
child: (state, _) {
|
||||
final rooms = state.rooms;
|
||||
if (rooms == null) return const SizedBox.shrink();
|
||||
|
||||
if(data.primaryLoading()) return const LoadingSpinner();
|
||||
latestData = data;
|
||||
var chats = <ChatTile>[];
|
||||
for (var chatRoom in data.getRoomsResponse.sortBy(
|
||||
final talkSettings = context.watch<SettingsCubit>().val().talkSettings;
|
||||
final sorted = rooms.sortBy(
|
||||
lastActivity: true,
|
||||
favoritesToTop: Provider.of<SettingsProvider>(context).val().talkSettings.sortFavoritesToTop,
|
||||
unreadToTop: Provider.of<SettingsProvider>(context).val().talkSettings.sortUnreadToTop,
|
||||
)
|
||||
) {
|
||||
var hasDraft = settings.val().talkSettings.drafts.containsKey(chatRoom.token);
|
||||
chats.add(ChatTile(data: chatRoom, query: _query, hasDraft: hasDraft));
|
||||
}
|
||||
favoritesToTop: talkSettings.sortFavoritesToTop,
|
||||
unreadToTop: talkSettings.sortUnreadToTop,
|
||||
);
|
||||
|
||||
return RefreshIndicator(
|
||||
color: Theme.of(context).primaryColor,
|
||||
onRefresh: () {
|
||||
_query(renew: true);
|
||||
return Future.delayed(const Duration(seconds: 3));
|
||||
},
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: chats
|
||||
),
|
||||
return ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: sorted.map((room) {
|
||||
final hasDraft = _settings.val().talkSettings.drafts.containsKey(room.token);
|
||||
return ChatTile(data: room, hasDraft: hasDraft);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../extensions/dateTime.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../api/marianumcloud/talk/chat/getChatResponse.dart';
|
||||
import '../../../api/marianumcloud/talk/room/getRoomResponse.dart';
|
||||
import '../../../extensions/dateTime.dart';
|
||||
import '../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||
import '../../../state/app/modules/chat/bloc/chat_state.dart';
|
||||
import '../../../theming/appTheme.dart';
|
||||
import '../../../model/chatList/chatProps.dart';
|
||||
import '../../../widget/clickableAppBar.dart';
|
||||
import '../../../widget/loadingSpinner.dart';
|
||||
import '../../../widget/userAvatar.dart';
|
||||
@@ -27,66 +27,63 @@ class ChatView extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ChatViewState extends State<ChatView> {
|
||||
|
||||
final ScrollController _listController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void _query({bool renew = false}) {
|
||||
Provider.of<ChatProps>(context, listen: false).setQueryToken(widget.room.token);
|
||||
void _refresh() {
|
||||
context.read<ChatBloc>().setToken(widget.room.token);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Consumer<ChatProps>(
|
||||
builder: (context, data, child) {
|
||||
var messages = List<Widget>.empty(growable: true);
|
||||
Widget build(BuildContext context) => BlocBuilder<ChatBloc, dynamic>(
|
||||
builder: (context, _) {
|
||||
final state = context.watch<ChatBloc>().state.data ?? const ChatState();
|
||||
final response = state.chatResponse;
|
||||
final isLoading = response == null;
|
||||
|
||||
if(!data.primaryLoading()) {
|
||||
final messages = <Widget>[];
|
||||
|
||||
if (response != null) {
|
||||
var lastDate = DateTime.now();
|
||||
data.getChatResponse.sortByTimestamp().forEach((element) {
|
||||
var elementDate = DateTime.fromMillisecondsSinceEpoch(element.timestamp * 1000);
|
||||
for (final element in response.sortByTimestamp()) {
|
||||
final elementDate = DateTime.fromMillisecondsSinceEpoch(element.timestamp * 1000);
|
||||
|
||||
if(element.systemMessage.contains('reaction')) return;
|
||||
if(element.systemMessage.contains('poll_voted')) return;
|
||||
var commonRead = int.parse(data.getChatResponse.headers?['x-chat-last-common-read'] ?? '0');
|
||||
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');
|
||||
|
||||
if(!elementDate.isSameDay(lastDate)) {
|
||||
if (!elementDate.isSameDay(lastDate)) {
|
||||
lastDate = elementDate;
|
||||
messages.add(ChatBubble(
|
||||
context: context,
|
||||
isSender: false,
|
||||
bubbleData: GetChatResponseObject.getDateDummy(element.timestamp),
|
||||
chatData: widget.room,
|
||||
refetch: _query,
|
||||
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: _query,
|
||||
isRead: element.id <= commonRead,
|
||||
selfId: widget.selfId,
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
if(data.getChatResponse.data.length >= 200) {
|
||||
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'
|
||||
'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: _query,
|
||||
refetch: ({bool renew = false}) => _refresh(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -94,9 +91,7 @@ class _ChatViewState extends State<ChatView> {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xffefeae2),
|
||||
appBar: ClickableAppBar(
|
||||
onTap: () {
|
||||
TalkNavigator.pushSplitView(context, ChatInfo(widget.room));
|
||||
},
|
||||
onTap: () => TalkNavigator.pushSplitView(context, ChatInfo(widget.room)),
|
||||
appBar: AppBar(
|
||||
title: Row(
|
||||
children: [
|
||||
@@ -104,7 +99,7 @@ class _ChatViewState extends State<ChatView> {
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(widget.room.displayName, overflow: TextOverflow.ellipsis, maxLines: 1),
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -117,26 +112,27 @@ class _ChatViewState extends State<ChatView> {
|
||||
opacity: 1,
|
||||
repeat: ImageRepeat.repeat,
|
||||
invertColors: AppTheme.isDarkMode(context),
|
||||
)
|
||||
),
|
||||
),
|
||||
child: data.primaryLoading() ? const LoadingSpinner() : Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
reverse: true,
|
||||
controller: _listController,
|
||||
children: messages.reversed.toList(),
|
||||
child: isLoading
|
||||
? const LoadingSpinner()
|
||||
: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
reverse: true,
|
||||
controller: _listController,
|
||||
children: messages.reversed.toList(),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
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)
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -16,8 +16,8 @@ class AnswerReference extends StatelessWidget {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: referenceMessage.actorId == selfId
|
||||
? style.getSelfStyle(false).color!.withGreen(200).withOpacity(0.2)
|
||||
: style.getRemoteStyle(false).color!.withWhite(200).withOpacity(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(
|
||||
color: referenceMessage.actorId == selfId
|
||||
|
||||
@@ -8,7 +8,7 @@ import 'package:jiffy/jiffy.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import '../../../../api/marianumcloud/talk/getPoll/getPollState.dart';
|
||||
import '../../../../extensions/text.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../api/marianumcloud/talk/chat/getChatResponse.dart';
|
||||
import '../../../../api/marianumcloud/talk/deleteMessage/deleteMessage.dart';
|
||||
@@ -17,7 +17,7 @@ import '../../../../api/marianumcloud/talk/deleteReactMessage/deleteReactMessage
|
||||
import '../../../../api/marianumcloud/talk/reactMessage/reactMessage.dart';
|
||||
import '../../../../api/marianumcloud/talk/reactMessage/reactMessageParams.dart';
|
||||
import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart';
|
||||
import '../../../../model/chatList/chatProps.dart';
|
||||
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||
import '../../../../widget/debug/debugTile.dart';
|
||||
import '../../../../widget/loadingSpinner.dart';
|
||||
import '../../files/fileElement.dart';
|
||||
@@ -189,9 +189,9 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.reply_outlined),
|
||||
title: const Text('Antworten'),
|
||||
onTap: () => {
|
||||
Provider.of<ChatProps>(context, listen: false).setReferenceMessageId(widget.bubbleData.id, context, widget.chatData.token),
|
||||
Navigator.of(context).pop(),
|
||||
onTap: () {
|
||||
context.read<ChatBloc>().setReferenceMessageId(widget.bubbleData.id);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -236,7 +236,8 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
|
||||
title: const Text('Nachricht löschen'),
|
||||
onTap: () {
|
||||
DeleteMessage(widget.chatData.token, widget.bubbleData.id).run().then((value) {
|
||||
Provider.of<ChatProps>(context, listen: false).run();
|
||||
if (!context.mounted) return;
|
||||
context.read<ChatBloc>().refresh();
|
||||
Navigator.of(context).pop();
|
||||
});
|
||||
},
|
||||
@@ -294,7 +295,7 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
|
||||
_position = const Offset(0, 0);
|
||||
});
|
||||
if(widget.bubbleData.isReplyable && isAction) {
|
||||
Provider.of<ChatProps>(context, listen: false).setReferenceMessageId(widget.bubbleData.id, context, widget.chatData.token);
|
||||
context.read<ChatBloc>().setReferenceMessageId(widget.bubbleData.id);
|
||||
}
|
||||
},
|
||||
onLongPress: showOptionsDialog,
|
||||
@@ -341,6 +342,7 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
|
||||
TextButton(onPressed: () {
|
||||
downloadCore?.then((value) {
|
||||
if(!value.isCancelled) value.cancel();
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
});
|
||||
setState(() {
|
||||
|
||||
@@ -5,14 +5,16 @@ import '../../../../theming/appTheme.dart';
|
||||
|
||||
extension ColorExtensions on Color {
|
||||
Color invert() {
|
||||
final r = 255 - red;
|
||||
final g = 255 - green;
|
||||
final b = 255 - blue;
|
||||
|
||||
return Color.fromARGB((opacity * 255).round(), r, g, b);
|
||||
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);
|
||||
}
|
||||
|
||||
Color withWhite(int whiteValue) => Color.fromARGB(alpha, whiteValue, whiteValue, whiteValue);
|
||||
Color withWhite(int whiteValue) {
|
||||
final value = whiteValue / 255.0;
|
||||
return Color.from(alpha: a, red: value, green: value, blue: value);
|
||||
}
|
||||
}
|
||||
|
||||
class ChatBubbleStyles {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:nextcloud/nextcloud.dart';
|
||||
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../../api/marianumcloud/files-sharing/fileSharingApi.dart';
|
||||
import '../../../../api/marianumcloud/files-sharing/fileSharingApiParams.dart';
|
||||
import '../../../../api/marianumcloud/talk/sendMessage/sendMessage.dart';
|
||||
import '../../../../api/marianumcloud/talk/sendMessage/sendMessageParams.dart';
|
||||
import '../../../../api/marianumcloud/webdav/webdavApi.dart';
|
||||
import '../../../../model/chatList/chatProps.dart';
|
||||
import '../../../../storage/base/settingsProvider.dart';
|
||||
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../../widget/filePick.dart';
|
||||
import '../../../../widget/focusBehaviour.dart';
|
||||
import '../../files/filesUploadDialog.dart';
|
||||
@@ -20,6 +20,7 @@ import 'answerReference.dart';
|
||||
class ChatTextfield extends StatefulWidget {
|
||||
final String sendToToken;
|
||||
final String? selfId;
|
||||
|
||||
const ChatTextfield(this.sendToToken, {this.selfId, super.key});
|
||||
|
||||
@override
|
||||
@@ -27,207 +28,214 @@ class ChatTextfield extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ChatTextfieldState extends State<ChatTextfield> {
|
||||
late SettingsProvider settings;
|
||||
late SettingsCubit settings;
|
||||
final TextEditingController _textBoxController = TextEditingController();
|
||||
bool isLoading = false;
|
||||
|
||||
void _query() {
|
||||
Provider.of<ChatProps>(context, listen: false).run();
|
||||
}
|
||||
|
||||
void share(String shareFolder, List<String> filePaths) {
|
||||
for (var element in filePaths) {
|
||||
var fileName = element.split(Platform.pathSeparator).last;
|
||||
for (final element in filePaths) {
|
||||
final fileName = element.split(Platform.pathSeparator).last;
|
||||
FileSharingApi().share(FileSharingApiParams(
|
||||
shareType: 10,
|
||||
shareWith: widget.sendToToken,
|
||||
path: '$shareFolder/$fileName',
|
||||
)).then((value) => _query());
|
||||
shareType: 10,
|
||||
shareWith: widget.sendToToken,
|
||||
path: '$shareFolder/$fileName',
|
||||
)).then((_) {
|
||||
if (mounted) context.read<ChatBloc>().refresh();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> mediaUpload(List<String>? paths) async {
|
||||
if (paths == null) return;
|
||||
|
||||
var shareFolder = 'MarianumMobile';
|
||||
WebdavApi.webdav.then((webdav) {
|
||||
webdav.mkcol(PathUri.parse('/$shareFolder'));
|
||||
});
|
||||
const shareFolder = 'MarianumMobile';
|
||||
WebdavApi.webdav.then((webdav) => webdav.mkcol(PathUri.parse('/$shareFolder')));
|
||||
|
||||
if (!mounted) return;
|
||||
pushScreen(
|
||||
context,
|
||||
withNavBar: false,
|
||||
screen: FilesUploadDialog(
|
||||
filePaths: paths,
|
||||
remotePath: shareFolder,
|
||||
onUploadFinished: (uploadedFilePaths) {
|
||||
share(shareFolder, uploadedFilePaths);
|
||||
},
|
||||
onUploadFinished: (uploaded) => share(shareFolder, uploaded),
|
||||
uniqueNames: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void setDraft(String text) {
|
||||
if(text.isNotEmpty) {
|
||||
settings.val(write: true).talkSettings.drafts[widget.sendToToken] = text;
|
||||
void _setDraft(String text) {
|
||||
final talkSettings = settings.val(write: true).talkSettings;
|
||||
if (text.isNotEmpty) {
|
||||
talkSettings.drafts[widget.sendToToken] = text;
|
||||
} else {
|
||||
settings.val(write: true).talkSettings.drafts.removeWhere((key, value) => key == widget.sendToToken);
|
||||
talkSettings.drafts.removeWhere((key, _) => key == widget.sendToToken);
|
||||
}
|
||||
}
|
||||
|
||||
void _setDraftReply(int? messageId) {
|
||||
final talkSettings = settings.val(write: true).talkSettings;
|
||||
if (messageId != null) {
|
||||
talkSettings.draftReplies[widget.sendToToken] = messageId;
|
||||
} else {
|
||||
talkSettings.draftReplies.removeWhere((key, _) => key == widget.sendToToken);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
settings = Provider.of<SettingsProvider>(context, listen: false);
|
||||
Provider.of<ChatProps>(context, listen: false).unsafeInternalSetReferenceMessageId =
|
||||
settings.val().talkSettings.draftReplies[widget.sendToToken];
|
||||
settings = context.read<SettingsCubit>();
|
||||
final draftReply = settings.val().talkSettings.draftReplies[widget.sendToToken];
|
||||
if (draftReply != null) {
|
||||
context.read<ChatBloc>().setReferenceMessageId(draftReply);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_textBoxController.text = settings.val().talkSettings.drafts[widget.sendToToken] ?? '';
|
||||
final chatBloc = context.watch<ChatBloc>();
|
||||
final chatState = chatBloc.state.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: [
|
||||
Consumer<ChatProps>(
|
||||
builder: (context, data, child) {
|
||||
if(data.getReferenceMessageId != null) {
|
||||
var referenceMessage = data.getChatResponse.sortByTimestamp().where((element) => element.id == data.getReferenceMessageId).last;
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AnswerReference(
|
||||
context: context,
|
||||
referenceMessage: referenceMessage,
|
||||
selfId: widget.selfId,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => data.setReferenceMessageId(null, context, widget.sendToToken),
|
||||
icon: const Icon(Icons.close_outlined),
|
||||
padding: const EdgeInsets.only(left: 0),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
},
|
||||
),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
GestureDetector(
|
||||
onTap: (){
|
||||
showDialog(context: context, builder: (context) => SimpleDialog(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.file_open),
|
||||
title: const Text('Aus Dateien auswählen'),
|
||||
onTap: () {
|
||||
FilePick.documentPick().then(mediaUpload);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
Visibility(
|
||||
visible: !Platform.isIOS,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.image),
|
||||
title: const Text('Aus Gallerie auswählen'),
|
||||
onTap: () {
|
||||
FilePick.multipleGalleryPick().then((value) {
|
||||
if(value != null) mediaUpload(value.map((e) => e.path).toList());
|
||||
});
|
||||
Navigator.of(context).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, ),
|
||||
),
|
||||
)
|
||||
),
|
||||
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: (String text) {
|
||||
if(text.trim().toLowerCase() == 'marbot marbot marbot') {
|
||||
var newText = 'Roboter sind cool und so, aber Marbots sind besser!';
|
||||
_textBoxController.text = newText;
|
||||
text = newText;
|
||||
}
|
||||
setDraft(text);
|
||||
},
|
||||
onTapOutside: (PointerDownEvent event) => FocusBehaviour.textFieldTapOutside(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
FloatingActionButton(
|
||||
mini: true,
|
||||
onPressed: () {
|
||||
if(_textBoxController.text.isEmpty) return;
|
||||
if(isLoading) return;
|
||||
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
SendMessage(widget.sendToToken, SendMessageParams(
|
||||
_textBoxController.text,
|
||||
replyTo: Provider.of<ChatProps>(context, listen: false).getReferenceMessageId.toString()
|
||||
)).run().then((value) {
|
||||
_query();
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
_textBoxController.text = '';
|
||||
setDraft('');
|
||||
Provider.of<ChatProps>(context, listen: false).setReferenceMessageId(null, context, widget.sendToToken);
|
||||
});
|
||||
},
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
elevation: 5,
|
||||
child: isLoading
|
||||
? Container(padding: const EdgeInsets.all(10), child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||
: const Icon(Icons.send, color: Colors.white, size: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
Widget replyBanner = const SizedBox.shrink();
|
||||
if (chatState != null && chatState.referenceMessageId != null && chatState.chatResponse != null) {
|
||||
try {
|
||||
final referenceMessage = chatState.chatResponse!.sortByTimestamp().firstWhere(
|
||||
(e) => e.id == chatState.referenceMessageId,
|
||||
);
|
||||
replyBanner = Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AnswerReference(
|
||||
context: context,
|
||||
referenceMessage: referenceMessage,
|
||||
selfId: widget.selfId,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
chatBloc.setReferenceMessageId(null);
|
||||
_setDraftReply(null);
|
||||
},
|
||||
icon: const Icon(Icons.close_outlined),
|
||||
padding: const EdgeInsets.only(left: 0),
|
||||
),
|
||||
],
|
||||
);
|
||||
} catch (_) {/* reference no longer in current chat data */}
|
||||
}
|
||||
|
||||
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,
|
||||
Row(children: <Widget>[
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
showDialog(context: context, builder: (dialogCtx) => SimpleDialog(children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.file_open),
|
||||
title: const Text('Aus Dateien auswählen'),
|
||||
onTap: () {
|
||||
FilePick.documentPick().then(mediaUpload);
|
||||
Navigator.of(dialogCtx).pop();
|
||||
},
|
||||
),
|
||||
Visibility(
|
||||
visible: !Platform.isIOS,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.image),
|
||||
title: const Text('Aus Gallerie auswählen'),
|
||||
onTap: () {
|
||||
FilePick.multipleGalleryPick().then((value) {
|
||||
if (value != null) mediaUpload(value.map((e) => e.path).toList());
|
||||
});
|
||||
Navigator.of(dialogCtx).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),
|
||||
),
|
||||
),
|
||||
),
|
||||
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),
|
||||
FloatingActionButton(
|
||||
mini: true,
|
||||
onPressed: () {
|
||||
if (_textBoxController.text.isEmpty || isLoading) return;
|
||||
|
||||
setState(() => isLoading = true);
|
||||
SendMessage(
|
||||
widget.sendToToken,
|
||||
SendMessageParams(
|
||||
_textBoxController.text,
|
||||
replyTo: chatBloc.state.data?.referenceMessageId?.toString(),
|
||||
),
|
||||
).run().then((_) {
|
||||
if (!mounted) return;
|
||||
chatBloc.refresh();
|
||||
setState(() => isLoading = false);
|
||||
_textBoxController.text = '';
|
||||
_setDraft('');
|
||||
chatBloc.setReferenceMessageId(null);
|
||||
_setDraftReply(null);
|
||||
});
|
||||
},
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
elevation: 5,
|
||||
child: isLoading
|
||||
? Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.send, color: Colors.white, size: 18),
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:jiffy/jiffy.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../../../api/marianumcloud/talk/chat/richObjectStringProcessor.dart';
|
||||
import '../../../../api/marianumcloud/talk/leaveRoom/leaveRoom.dart';
|
||||
@@ -10,7 +9,9 @@ import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart';
|
||||
import '../../../../api/marianumcloud/talk/setFavorite/setFavorite.dart';
|
||||
import '../../../../api/marianumcloud/talk/setReadMarker/setReadMarker.dart';
|
||||
import '../../../../api/marianumcloud/talk/setReadMarker/setReadMarkerParams.dart';
|
||||
import '../../../../model/chatList/chatProps.dart';
|
||||
import '../../../../model/accountData.dart';
|
||||
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||
import '../../../../state/app/modules/chatList/bloc/chat_list_bloc.dart';
|
||||
import '../../../../widget/confirmDialog.dart';
|
||||
import '../../../../widget/debug/debugTile.dart';
|
||||
import '../../../../widget/userAvatar.dart';
|
||||
@@ -19,167 +20,177 @@ import '../talkNavigator.dart';
|
||||
|
||||
class ChatTile extends StatefulWidget {
|
||||
final GetRoomResponseObject data;
|
||||
final void Function({bool renew}) query;
|
||||
final bool disableContextActions;
|
||||
final bool hasDraft;
|
||||
|
||||
const ChatTile({super.key, required this.data, required this.query, this.disableContextActions = false, this.hasDraft = false});
|
||||
const ChatTile({super.key, required this.data, this.disableContextActions = false, this.hasDraft = false});
|
||||
|
||||
@override
|
||||
State<ChatTile> createState() => _ChatTileState();
|
||||
}
|
||||
|
||||
class _ChatTileState extends State<ChatTile> {
|
||||
late String selfUsername;
|
||||
String? selfUsername;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
SharedPreferences.getInstance().then((value) => {
|
||||
selfUsername = value.getString('username')!
|
||||
AccountData().waitForPopulation().then((_) {
|
||||
if (!mounted) return;
|
||||
setState(() => selfUsername = AccountData().isPopulated() ? AccountData().getUsername() : null);
|
||||
});
|
||||
}
|
||||
|
||||
void _refreshList() => context.read<ChatListBloc>().refresh();
|
||||
|
||||
void setCurrentAsRead() {
|
||||
SetReadMarker(
|
||||
widget.data.token,
|
||||
true,
|
||||
setReadMarkerParams: SetReadMarkerParams(
|
||||
lastReadMessage: widget.data.lastMessage.id
|
||||
)
|
||||
).run().then((value) => widget.query(renew: true));
|
||||
widget.data.token,
|
||||
true,
|
||||
setReadMarkerParams: SetReadMarkerParams(lastReadMessage: widget.data.lastMessage.id),
|
||||
).run().then((_) {
|
||||
if (!mounted) return;
|
||||
_refreshList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Consumer<ChatProps>(builder: (context, chatData, child) {
|
||||
var isGroup = widget.data.type != GetRoomResponseObjectConversationType.oneToOne;
|
||||
var circleAvatar = UserAvatar(id: isGroup ? widget.data.token : widget.data.name, isGroup: isGroup);
|
||||
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);
|
||||
|
||||
return ListTile(
|
||||
style: ListTileStyle.list,
|
||||
tileColor: chatData.currentToken() == widget.data.token && TalkNavigator.isSecondaryVisible(context)
|
||||
? Theme.of(context).primaryColor.withAlpha(100)
|
||||
: null,
|
||||
leading: Stack(
|
||||
style: ListTileStyle.list,
|
||||
tileColor: chatBloc.state.data?.currentToken == widget.data.token && TalkNavigator.isSecondaryVisible(context)
|
||||
? Theme.of(context).primaryColor.withAlpha(100)
|
||||
: null,
|
||||
leading: Stack(
|
||||
children: [
|
||||
circleAvatar,
|
||||
Visibility(
|
||||
visible: widget.data.isFavorite,
|
||||
child: Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(1),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).primaryColor.withAlpha(200),
|
||||
borderRadius: BorderRadius.circular(90.0),
|
||||
),
|
||||
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)),
|
||||
if (widget.hasDraft) ...[
|
||||
const SizedBox(width: 5),
|
||||
const Icon(Icons.edit_outlined, size: 15),
|
||||
],
|
||||
],
|
||||
),
|
||||
subtitle: Text(
|
||||
'${Jiffy.parseFromMillisecondsSinceEpoch(widget.data.lastMessage.timestamp * 1000).fromNow()}: '
|
||||
'${RichObjectStringProcessor.parseToString(widget.data.lastMessage.message.replaceAll("\n", " "), widget.data.lastMessage.messageParameters)}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: widget.data.unreadMessages <= 0
|
||||
? null
|
||||
: Container(
|
||||
padding: const EdgeInsets.all(1),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).primaryColor,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
constraints: const BoxConstraints(minWidth: 20, minHeight: 20),
|
||||
child: Text(
|
||||
'${widget.data.unreadMessages}',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 15),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
if (selfUsername == null) return;
|
||||
setCurrentAsRead();
|
||||
final view = ChatView(room: widget.data, selfId: selfUsername!, avatar: circleAvatar);
|
||||
TalkNavigator.pushSplitView(context, view, overrideToSingleSubScreen: true);
|
||||
context.read<ChatBloc>().setToken(widget.data.token);
|
||||
},
|
||||
onLongPress: () {
|
||||
if (widget.disableContextActions) return;
|
||||
showDialog(context: context, builder: (dialogCtx) => SimpleDialog(
|
||||
children: [
|
||||
circleAvatar,
|
||||
Visibility(
|
||||
visible: widget.data.isFavorite,
|
||||
child: Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(1),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).primaryColor.withAlpha(200),
|
||||
borderRadius: BorderRadius.circular(90.0),
|
||||
),
|
||||
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),
|
||||
),
|
||||
if(widget.hasDraft) ...[
|
||||
const SizedBox(width: 5),
|
||||
const Icon(Icons.edit_outlined, size: 15),
|
||||
],
|
||||
],
|
||||
),
|
||||
subtitle: Text("${Jiffy.parseFromMillisecondsSinceEpoch(widget.data.lastMessage.timestamp * 1000).fromNow()}: ${RichObjectStringProcessor.parseToString(widget.data.lastMessage.message.replaceAll("\n", " "), widget.data.lastMessage.messageParameters)}", overflow: TextOverflow.ellipsis),
|
||||
trailing: widget.data.unreadMessages <= 0
|
||||
? null
|
||||
: Container(
|
||||
padding: const EdgeInsets.all(1),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).primaryColor,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 20,
|
||||
minHeight: 20,
|
||||
),
|
||||
child: Text(
|
||||
'${widget.data.unreadMessages}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
onTap: () async {
|
||||
setCurrentAsRead();
|
||||
var view = ChatView(room: widget.data, selfId: selfUsername, avatar: circleAvatar);
|
||||
TalkNavigator.pushSplitView(context, view, overrideToSingleSubScreen: true);
|
||||
Provider.of<ChatProps>(context, listen: false).setQueryToken(widget.data.token);
|
||||
},
|
||||
onLongPress: () {
|
||||
if(widget.disableContextActions) return;
|
||||
showDialog(context: context, builder: (context) => SimpleDialog(
|
||||
children: [
|
||||
Visibility(
|
||||
visible: widget.data.unreadMessages > 0,
|
||||
replacement: ListTile(
|
||||
leading: const Icon(Icons.mark_chat_unread_outlined),
|
||||
title: const Text('Als ungelesen markieren'),
|
||||
onTap: () {
|
||||
SetReadMarker(widget.data.token, false).run().then((value) => widget.query(renew: true));
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.mark_chat_read_outlined),
|
||||
title: const Text('Als gelesen markieren'),
|
||||
onTap: () {
|
||||
setCurrentAsRead();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
Visibility(
|
||||
visible: widget.data.isFavorite,
|
||||
replacement: ListTile(
|
||||
leading: const Icon(Icons.star_outline),
|
||||
title: const Text('Zu Favoriten hinzufügen'),
|
||||
onTap: () {
|
||||
SetFavorite(widget.data.token, true).run().then((value) => widget.query(renew: true));
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.stars_outlined),
|
||||
title: const Text('Von Favoriten entfernen'),
|
||||
onTap: () {
|
||||
SetFavorite(widget.data.token, false).run().then((value) => widget.query(renew: true));
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline),
|
||||
title: const Text('Konversation verlassen'),
|
||||
visible: widget.data.unreadMessages > 0,
|
||||
replacement: ListTile(
|
||||
leading: const Icon(Icons.mark_chat_unread_outlined),
|
||||
title: const Text('Als ungelesen markieren'),
|
||||
onTap: () {
|
||||
ConfirmDialog(
|
||||
title: 'Chat verlassen',
|
||||
content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.',
|
||||
confirmButton: 'Löschen',
|
||||
onConfirm: () {
|
||||
LeaveRoom(widget.data.token).run().then((value) => widget.query(renew: true));
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
).asDialog(context);
|
||||
SetReadMarker(widget.data.token, false).run().then((_) {
|
||||
if (mounted) _refreshList();
|
||||
});
|
||||
Navigator.of(dialogCtx).pop();
|
||||
},
|
||||
),
|
||||
DebugTile(context).jsonData(widget.data.toJson()),
|
||||
],
|
||||
));
|
||||
},
|
||||
);
|
||||
});
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.mark_chat_read_outlined),
|
||||
title: const Text('Als gelesen markieren'),
|
||||
onTap: () {
|
||||
setCurrentAsRead();
|
||||
Navigator.of(dialogCtx).pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
Visibility(
|
||||
visible: widget.data.isFavorite,
|
||||
replacement: ListTile(
|
||||
leading: const Icon(Icons.star_outline),
|
||||
title: const Text('Zu Favoriten hinzufügen'),
|
||||
onTap: () {
|
||||
SetFavorite(widget.data.token, true).run().then((_) {
|
||||
if (mounted) _refreshList();
|
||||
});
|
||||
Navigator.of(dialogCtx).pop();
|
||||
},
|
||||
),
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.stars_outlined),
|
||||
title: const Text('Von Favoriten entfernen'),
|
||||
onTap: () {
|
||||
SetFavorite(widget.data.token, false).run().then((_) {
|
||||
if (mounted) _refreshList();
|
||||
});
|
||||
Navigator.of(dialogCtx).pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline),
|
||||
title: const Text('Konversation verlassen'),
|
||||
onTap: () {
|
||||
ConfirmDialog(
|
||||
title: 'Chat verlassen',
|
||||
content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.',
|
||||
confirmButton: 'Löschen',
|
||||
onConfirm: () {
|
||||
LeaveRoom(widget.data.token).run().then((_) {
|
||||
if (mounted) _refreshList();
|
||||
});
|
||||
Navigator.of(dialogCtx).pop();
|
||||
},
|
||||
).asDialog(dialogCtx);
|
||||
},
|
||||
),
|
||||
DebugTile(dialogCtx).jsonData(widget.data.toJson()),
|
||||
],
|
||||
));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ class _PollOptionsListState extends State<PollOptionsList> {
|
||||
? (widget.pollData.votes['option-$optionId'] as num?) ?? 0
|
||||
: 0;
|
||||
var numVoters = widget.pollData.numVoters ?? 0;
|
||||
double portion = numVoters == 0 ? 0 : (votes / numVoters);
|
||||
final portion = numVoters == 0 ? 0.0 : (votes / numVoters);
|
||||
|
||||
return ListTile(
|
||||
// enabled: false,
|
||||
|
||||
@@ -25,7 +25,7 @@ class SearchChat extends SearchDelegate {
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
var item = items.elementAt(index);
|
||||
return ChatTile(data: item, disableContextActions: true, query: ({bool renew = true}) {});
|
||||
return ChatTile(data: item, disableContextActions: true);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
|
||||
import 'CrossPainter.dart';
|
||||
|
||||
class AppointmentComponent extends StatefulWidget {
|
||||
final CalendarAppointmentDetails details;
|
||||
final bool crossedOut;
|
||||
const AppointmentComponent({super.key, required this.details, this.crossedOut = false});
|
||||
|
||||
@override
|
||||
State<AppointmentComponent> createState() => _AppointmentComponentState();
|
||||
}
|
||||
|
||||
class _AppointmentComponentState extends State<AppointmentComponent> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Appointment meeting = widget.details.appointments.first;
|
||||
final appointmentHeight = widget.details.bounds.height;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(3),
|
||||
height: appointmentHeight,
|
||||
alignment: Alignment.topLeft,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.rectangle,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(5)),
|
||||
color: meeting.color.withAlpha(meeting.endTime.isBefore(DateTime.now()) ? 100 : 255),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
FittedBox(
|
||||
fit: BoxFit.fitWidth,
|
||||
child: Text(
|
||||
meeting.subject,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
),
|
||||
),
|
||||
FittedBox(
|
||||
fit: BoxFit.fitWidth,
|
||||
child: Text(
|
||||
(meeting.location == null || meeting.location!.isEmpty ? ' ' : meeting.location!),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: true,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Visibility(
|
||||
visible: widget.crossedOut,
|
||||
child: Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
width: 2,
|
||||
color: Colors.red.withAlpha(200),
|
||||
),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(5)),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: CrossPainter(),
|
||||
),
|
||||
)
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:jiffy/jiffy.dart';
|
||||
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:rrule/rrule.dart';
|
||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
|
||||
import '../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart';
|
||||
import '../../../api/mhsl/customTimetableEvent/remove/removeCustomTimetableEvent.dart';
|
||||
import '../../../api/mhsl/customTimetableEvent/remove/removeCustomTimetableEventParams.dart';
|
||||
import '../../../api/webuntis/queries/getRooms/getRoomsResponse.dart';
|
||||
import '../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart';
|
||||
import '../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart';
|
||||
import '../../../model/timetable/timetableProps.dart';
|
||||
import '../../../widget/centeredLeading.dart';
|
||||
import '../../../widget/confirmDialog.dart';
|
||||
import '../../../widget/debug/debugTile.dart';
|
||||
import '../../../widget/unimplementedDialog.dart';
|
||||
import '../more/roomplan/roomplan.dart';
|
||||
import 'arbitraryAppointment.dart';
|
||||
import 'customTimetableEventEditDialog.dart';
|
||||
|
||||
class AppointmentDetails {
|
||||
static String _getEventPrefix(String? code) {
|
||||
if(code == 'cancelled') return 'Entfällt: ';
|
||||
if(code == 'irregular') return 'Änderung: ';
|
||||
return code ?? '';
|
||||
}
|
||||
|
||||
static void show(BuildContext context, TimetableProps webuntisData, Appointment appointment) {
|
||||
(appointment.id! as ArbitraryAppointment).handlers(
|
||||
(webuntis) => _webuntis(context, webuntisData, appointment, webuntis),
|
||||
(customData) => _custom(context, webuntisData, customData)
|
||||
);
|
||||
}
|
||||
|
||||
static void _bottomSheet(
|
||||
BuildContext context,
|
||||
Widget Function(BuildContext context) header,
|
||||
SliverChildListDelegate Function(BuildContext context) body
|
||||
) {
|
||||
showStickyFlexibleBottomSheet(
|
||||
minHeight: 0,
|
||||
initHeight: 0.4,
|
||||
maxHeight: 0.7,
|
||||
anchors: [0, 0.4, 0.7],
|
||||
isSafeArea: true,
|
||||
maxHeaderHeight: 100,
|
||||
|
||||
context: context,
|
||||
headerBuilder: (context, bottomSheetOffset) => header(context),
|
||||
bodyBuilder: (context, bottomSheetOffset) => body(context)
|
||||
);
|
||||
}
|
||||
|
||||
static void _webuntis(BuildContext context, TimetableProps webuntisData, Appointment appointment, GetTimetableResponseObject timetableData) {
|
||||
GetSubjectsResponseObject subject;
|
||||
GetRoomsResponseObject room;
|
||||
|
||||
try {
|
||||
subject = webuntisData.getSubjectsResponse.result.firstWhere((subject) => subject.id == timetableData.su[0].id);
|
||||
} catch(e) {
|
||||
subject = GetSubjectsResponseObject(0, '?', 'Unbekannt', '?', true);
|
||||
}
|
||||
|
||||
try {
|
||||
room = webuntisData.getRoomsResponse.result.firstWhere((room) => room.id == timetableData.ro[0].id);
|
||||
} catch(e) {
|
||||
room = GetRoomsResponseObject(0, '?', 'Unbekannt', true, '?');
|
||||
}
|
||||
|
||||
_bottomSheet(
|
||||
context,
|
||||
(context) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('${_getEventPrefix(timetableData.code)}${subject.alternateName}', textAlign: TextAlign.center, style: const TextStyle(fontSize: 25), overflow: TextOverflow.ellipsis),
|
||||
Text(subject.longName),
|
||||
Text("${Jiffy.parseFromDateTime(appointment.startTime).format(pattern: "HH:mm")} - ${Jiffy.parseFromDateTime(appointment.endTime).format(pattern: "HH:mm")}", style: const TextStyle(fontSize: 15)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
(context) => SliverChildListDelegate(
|
||||
[
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.notifications_active),
|
||||
title: Text("Status: ${timetableData.code != null ? "Geändert" : "Regulär"}"),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.room),
|
||||
title: Text('Raum: ${room.name} (${room.longName})'),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.house_outlined),
|
||||
onPressed: () {
|
||||
pushScreen(context, withNavBar: false, screen: const Roomplan());
|
||||
},
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person),
|
||||
title: timetableData.te.isNotEmpty
|
||||
? Text("Lehrkraft: ${timetableData.te[0].name} ${timetableData.te[0].longname.isNotEmpty ? "(${timetableData.te[0].longname})" : ""}")
|
||||
: const Text('?'),
|
||||
trailing: Visibility(
|
||||
visible: !kReleaseMode,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.textsms_outlined),
|
||||
onPressed: () {
|
||||
UnimplementedDialog.show(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.abc),
|
||||
title: Text('Typ: ${timetableData.activityType}'),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.people),
|
||||
title: Text("Klasse(n): ${timetableData.kl.map((e) => e.name).join(", ")}"),
|
||||
),
|
||||
DebugTile(context).jsonData(timetableData.toJson()),
|
||||
],
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
static Completer deleteCustomEvent(BuildContext context, CustomTimetableEvent appointment) {
|
||||
var future = Completer();
|
||||
ConfirmDialog(
|
||||
title: 'Termin löschen',
|
||||
content: "Der ${appointment.rrule.isEmpty ? "Termin" : "Serientermin"} wird unwiederruflich gelöscht.",
|
||||
confirmButton: 'Löschen',
|
||||
onConfirm: () {
|
||||
RemoveCustomTimetableEvent(
|
||||
RemoveCustomTimetableEventParams(
|
||||
appointment.id
|
||||
)
|
||||
).run().then((value) {
|
||||
Provider.of<TimetableProps>(context, listen: false).run(renew: true);
|
||||
future.complete();
|
||||
});
|
||||
},
|
||||
).asDialog(context);
|
||||
return future;
|
||||
}
|
||||
|
||||
static void _custom(BuildContext context, TimetableProps webuntisData, CustomTimetableEvent appointment) {
|
||||
_bottomSheet(
|
||||
context,
|
||||
(context) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(appointment.title, style: const TextStyle(fontSize: 25, overflow: TextOverflow.ellipsis)),
|
||||
Text("${Jiffy.parseFromDateTime(appointment.startDate).format(pattern: "HH:mm")} - ${Jiffy.parseFromDateTime(appointment.endDate).format(pattern: "HH:mm")}", style: const TextStyle(fontSize: 15)),
|
||||
],
|
||||
),
|
||||
),
|
||||
(context) => SliverChildListDelegate(
|
||||
[
|
||||
const Divider(),
|
||||
Center(
|
||||
child: Wrap(
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => CustomTimetableEventEditDialog(existingEvent: appointment),
|
||||
);
|
||||
},
|
||||
label: const Text('Bearbeiten'),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
deleteCustomEvent(context, appointment).future.then((value) => Navigator.of(context).pop());
|
||||
},
|
||||
label: const Text('Löschen'),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: Text(appointment.description.isEmpty ? 'Keine Beschreibung' : appointment.description),
|
||||
),
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.repeat_outlined)),
|
||||
title: Text("Serie: ${appointment.rrule.isNotEmpty ? "Wiederholend" : "Einmailg"}"),
|
||||
subtitle: FutureBuilder(
|
||||
future: RruleL10nEn.create(),
|
||||
builder: (context, snapshot) {
|
||||
if(appointment.rrule.isEmpty) return const Text('Keine weiteren vorkomnisse');
|
||||
if(snapshot.data == null) return const Text('...');
|
||||
var rrule = RecurrenceRule.fromString(appointment.rrule);
|
||||
if(!rrule.canFullyConvertToText) return const Text('Keine genauere Angabe möglich.');
|
||||
return Text(rrule.toText(l10n: snapshot.data!));
|
||||
},
|
||||
)
|
||||
),
|
||||
DebugTile(context).child(
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.rule)),
|
||||
title: const Text('RRule'),
|
||||
subtitle: Text(appointment.rrule.isEmpty ? 'Keine' : appointment.rrule),
|
||||
)
|
||||
),
|
||||
DebugTile(context).jsonData(appointment.toJson()),
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
|
||||
import '../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart';
|
||||
import '../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart';
|
||||
|
||||
class ArbitraryAppointment {
|
||||
GetTimetableResponseObject? webuntis;
|
||||
CustomTimetableEvent? custom;
|
||||
|
||||
ArbitraryAppointment({this.webuntis, this.custom});
|
||||
|
||||
bool hasWebuntis() => webuntis != null;
|
||||
|
||||
bool hasCustom() => custom != null;
|
||||
|
||||
void handlers(void Function(GetTimetableResponseObject webuntisData) webuntis, void Function(CustomTimetableEvent customData) custom) {
|
||||
if(hasWebuntis()) webuntis(this.webuntis!);
|
||||
if(hasCustom()) custom(this.custom!);
|
||||
}
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:jiffy/jiffy.dart';
|
||||
import '../../../extensions/dateTime.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:rrule_generator/rrule_generator.dart';
|
||||
import 'package:time_range_picker/time_range_picker.dart';
|
||||
|
||||
import '../../../api/mhsl/customTimetableEvent/add/addCustomTimetableEvent.dart';
|
||||
import '../../../api/mhsl/customTimetableEvent/add/addCustomTimetableEventParams.dart';
|
||||
import '../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart';
|
||||
import '../../../api/mhsl/customTimetableEvent/update/updateCustomTimetableEvent.dart';
|
||||
import '../../../api/mhsl/customTimetableEvent/update/updateCustomTimetableEventParams.dart';
|
||||
import '../../../model/accountData.dart';
|
||||
import '../../../model/timetable/timetableProps.dart';
|
||||
import '../../../widget/focusBehaviour.dart';
|
||||
import '../../../widget/infoDialog.dart';
|
||||
import 'customTimetableColors.dart';
|
||||
|
||||
class CustomTimetableEventEditDialog extends StatefulWidget {
|
||||
final CustomTimetableEvent? existingEvent;
|
||||
const CustomTimetableEventEditDialog({this.existingEvent, super.key});
|
||||
|
||||
@override
|
||||
State<CustomTimetableEventEditDialog> createState() => _AddCustomTimetableEventDialogState();
|
||||
}
|
||||
|
||||
class _AddCustomTimetableEventDialogState extends State<CustomTimetableEventEditDialog> {
|
||||
late DateTime _date = widget.existingEvent?.startDate ?? DateTime.now();
|
||||
late TimeOfDay _startTime = widget.existingEvent?.startDate.toTimeOfDay() ?? const TimeOfDay(hour: 08, minute: 00);
|
||||
late TimeOfDay _endTime = widget.existingEvent?.endDate.toTimeOfDay() ?? const TimeOfDay(hour: 09, minute: 30);
|
||||
late final TextEditingController _eventName = TextEditingController(text: widget.existingEvent?.title);
|
||||
late final TextEditingController _eventDescription = TextEditingController(text: widget.existingEvent?.description);
|
||||
late String _recurringRule = widget.existingEvent?.rrule ?? '';
|
||||
late CustomTimetableColors _customTimetableColor = CustomTimetableColors.values.firstWhere(
|
||||
(element) => element.name == widget.existingEvent?.color,
|
||||
orElse: () => TimetableColors.defaultColor
|
||||
);
|
||||
|
||||
late bool isEditingExisting = widget.existingEvent != null;
|
||||
|
||||
bool validate() {
|
||||
if(_eventName.text.isEmpty) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void fetchTimetable() {
|
||||
Provider.of<TimetableProps>(context, listen: false).run(renew: true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AlertDialog(
|
||||
insetPadding: const EdgeInsets.all(20),
|
||||
contentPadding: const EdgeInsets.all(10),
|
||||
title: const Text('Termin hinzufügen'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
title: TextField(
|
||||
controller: _eventName,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Terminname',
|
||||
border: OutlineInputBorder()
|
||||
),
|
||||
onTapOutside: (PointerDownEvent event) => FocusBehaviour.textFieldTapOutside(context),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: TextField(
|
||||
controller: _eventDescription,
|
||||
maxLines: 2,
|
||||
minLines: 2,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Beschreibung',
|
||||
border: OutlineInputBorder()
|
||||
),
|
||||
onTapOutside: (PointerDownEvent event) => FocusBehaviour.textFieldTapOutside(context),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.date_range_outlined),
|
||||
title: Text(Jiffy.parseFromDateTime(_date).yMMMd),
|
||||
subtitle: const Text('Datum'),
|
||||
onTap: () async {
|
||||
final pickedDate = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _date,
|
||||
firstDate: DateTime.now().subtract(const Duration(days: 30)),
|
||||
lastDate: DateTime.now().add(const Duration(days: 30)),
|
||||
);
|
||||
if (pickedDate != null && pickedDate != _date) {
|
||||
setState(() {
|
||||
_date = pickedDate;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.access_time_outlined),
|
||||
title: Text('${_startTime.format(context).toString()} - ${_endTime.format(context).toString()}'),
|
||||
subtitle: const Text('Zeitraum'),
|
||||
onTap: () async {
|
||||
TimeRange timeRange = await showTimeRangePicker(
|
||||
context: context,
|
||||
start: _startTime,
|
||||
end: _endTime,
|
||||
disabledTime: TimeRange(startTime: const TimeOfDay(hour: 16, minute: 30), endTime: const TimeOfDay(hour: 08, minute: 00)),
|
||||
disabledColor: Colors.grey,
|
||||
paintingStyle: PaintingStyle.fill,
|
||||
interval: const Duration(minutes: 5),
|
||||
fromText: 'Beginnend',
|
||||
toText: 'Endend',
|
||||
strokeColor: Theme.of(context).colorScheme.secondary,
|
||||
minDuration: const Duration(minutes: 15),
|
||||
selectedColor: Theme.of(context).primaryColor,
|
||||
ticks: 24,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_startTime = timeRange.startTime;
|
||||
_endTime = timeRange.endTime;
|
||||
});
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.color_lens_outlined),
|
||||
title: const Text('Farbgebung'),
|
||||
trailing: DropdownButton<CustomTimetableColors>(
|
||||
value: _customTimetableColor,
|
||||
icon: const Icon(Icons.arrow_drop_down),
|
||||
items: CustomTimetableColors.values.map((e) => DropdownMenuItem<CustomTimetableColors>(
|
||||
value: e,
|
||||
enabled: e != _customTimetableColor,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.circle, color: TimetableColors.getDisplayOptions(e).color),
|
||||
const SizedBox(width: 10),
|
||||
Text(TimetableColors.getDisplayOptions(e).displayName),
|
||||
],
|
||||
),
|
||||
)).toList(),
|
||||
onChanged: (e) {
|
||||
setState(() {
|
||||
_customTimetableColor = e!;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
RRuleGenerator(
|
||||
config: RRuleGeneratorConfig(
|
||||
headerEnabled: true,
|
||||
weekdayBackgroundColor: Theme.of(context).colorScheme.secondary,
|
||||
weekdaySelectedBackgroundColor: Theme.of(context).primaryColor,
|
||||
weekdayColor: Colors.black,
|
||||
),
|
||||
initialRRule: _recurringRule,
|
||||
textDelegate: const GermanRRuleTextDelegate(),
|
||||
onChange: (String newValue) {
|
||||
log('Rule: $newValue');
|
||||
setState(() {
|
||||
_recurringRule = newValue;
|
||||
});
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
if(!validate()) return;
|
||||
|
||||
var editedEvent = CustomTimetableEvent(
|
||||
id: '',
|
||||
title: _eventName.text,
|
||||
description: _eventDescription.text,
|
||||
startDate: _date.withTime(_startTime),
|
||||
endDate: _date.withTime(_endTime),
|
||||
color: _customTimetableColor.name,
|
||||
rrule: _recurringRule,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
if(!isEditingExisting) {
|
||||
AddCustomTimetableEvent(
|
||||
AddCustomTimetableEventParams(
|
||||
AccountData().getUserSecret(),
|
||||
editedEvent
|
||||
)
|
||||
).run().then((value) {
|
||||
Navigator.of(context).pop();
|
||||
fetchTimetable();
|
||||
})
|
||||
.catchError((error, stack) {
|
||||
InfoDialog.show(context, error.toString());
|
||||
});
|
||||
} else {
|
||||
UpdateCustomTimetableEvent(
|
||||
UpdateCustomTimetableEventParams(
|
||||
widget.existingEvent?.id ?? '',
|
||||
editedEvent
|
||||
)
|
||||
).run().then((value) {
|
||||
Navigator.of(context).pop();
|
||||
fetchTimetable();
|
||||
})
|
||||
.catchError((error, stack) {
|
||||
InfoDialog.show(context, error.toString());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
},
|
||||
child: Text(isEditingExisting ? 'Speichern' : 'Erstellen'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
+8
-13
@@ -1,35 +1,30 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../theming/darkAppTheme.dart';
|
||||
import '../../../../theming/darkAppTheme.dart';
|
||||
|
||||
enum CustomTimetableColors {
|
||||
orange,
|
||||
red,
|
||||
green,
|
||||
blue
|
||||
}
|
||||
enum CustomTimetableColors { orange, red, green, blue }
|
||||
|
||||
class TimetableColors {
|
||||
static const CustomTimetableColors defaultColor = CustomTimetableColors.orange;
|
||||
|
||||
static ColorModeDisplay getDisplayOptions(CustomTimetableColors color) {
|
||||
switch(color) {
|
||||
switch (color) {
|
||||
case CustomTimetableColors.green:
|
||||
return ColorModeDisplay(color: Colors.green, displayName: 'Grün');
|
||||
|
||||
case CustomTimetableColors.blue:
|
||||
return ColorModeDisplay(color: Colors.blue, displayName: 'Blau');
|
||||
|
||||
case CustomTimetableColors.orange:
|
||||
return ColorModeDisplay(color: Colors.orange.shade800, displayName: 'Orange');
|
||||
|
||||
case CustomTimetableColors.red:
|
||||
return ColorModeDisplay(color: DarkAppTheme.marianumRed, displayName: 'Rot');
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
static Color getColorFromString(String color) => getDisplayOptions(CustomTimetableColors.values.firstWhere((element) => element.name == color, orElse: () => TimetableColors.defaultColor)).color;
|
||||
static Color getColorFromString(String color) =>
|
||||
getDisplayOptions(CustomTimetableColors.values.firstWhere(
|
||||
(e) => e.name == color,
|
||||
orElse: () => defaultColor,
|
||||
)).color;
|
||||
}
|
||||
|
||||
class ColorModeDisplay {
|
||||
@@ -0,0 +1,190 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:jiffy/jiffy.dart';
|
||||
import 'package:rrule_generator/rrule_generator.dart';
|
||||
import 'package:time_range_picker/time_range_picker.dart';
|
||||
|
||||
import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart';
|
||||
import '../../../../extensions/dateTime.dart';
|
||||
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||
import '../../../../widget/focusBehaviour.dart';
|
||||
import '../../../../widget/infoDialog.dart';
|
||||
import 'custom_event_colors.dart';
|
||||
|
||||
class CustomEventEditDialog extends StatefulWidget {
|
||||
final CustomTimetableEvent? existingEvent;
|
||||
|
||||
const CustomEventEditDialog({this.existingEvent, super.key});
|
||||
|
||||
@override
|
||||
State<CustomEventEditDialog> createState() => _CustomEventEditDialogState();
|
||||
}
|
||||
|
||||
class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
|
||||
late DateTime _date = widget.existingEvent?.startDate ?? DateTime.now();
|
||||
late TimeOfDay _startTime = widget.existingEvent?.startDate.toTimeOfDay() ?? const TimeOfDay(hour: 8, minute: 0);
|
||||
late TimeOfDay _endTime = widget.existingEvent?.endDate.toTimeOfDay() ?? const TimeOfDay(hour: 9, minute: 30);
|
||||
late final TextEditingController _name = TextEditingController(text: widget.existingEvent?.title);
|
||||
late final TextEditingController _description = TextEditingController(text: widget.existingEvent?.description);
|
||||
late String _rrule = widget.existingEvent?.rrule ?? '';
|
||||
late CustomTimetableColors _color = CustomTimetableColors.values.firstWhere(
|
||||
(e) => e.name == widget.existingEvent?.color,
|
||||
orElse: () => TimetableColors.defaultColor,
|
||||
);
|
||||
|
||||
bool get _isEditing => widget.existingEvent != null;
|
||||
|
||||
bool _validate() => _name.text.isNotEmpty;
|
||||
|
||||
void _save() {
|
||||
if (!_validate()) return;
|
||||
|
||||
final edited = CustomTimetableEvent(
|
||||
id: widget.existingEvent?.id ?? '',
|
||||
title: _name.text,
|
||||
description: _description.text,
|
||||
startDate: _date.withTime(_startTime),
|
||||
endDate: _date.withTime(_endTime),
|
||||
color: _color.name,
|
||||
rrule: _rrule,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
final bloc = context.read<TimetableBloc>();
|
||||
final future = _isEditing
|
||||
? bloc.updateCustomEvent(widget.existingEvent!.id, edited)
|
||||
: bloc.addCustomEvent(edited);
|
||||
|
||||
future.then((_) {
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
}).catchError((Object error) {
|
||||
if (!mounted) return;
|
||||
InfoDialog.show(context, error.toString());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickDate() async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _date,
|
||||
firstDate: DateTime.now().subtract(const Duration(days: 30)),
|
||||
lastDate: DateTime.now().add(const Duration(days: 30)),
|
||||
);
|
||||
if (picked != null && picked != _date) setState(() => _date = picked);
|
||||
}
|
||||
|
||||
Future<void> _pickTimeRange() async {
|
||||
final range = await showTimeRangePicker(
|
||||
context: context,
|
||||
start: _startTime,
|
||||
end: _endTime,
|
||||
disabledTime: TimeRange(
|
||||
startTime: const TimeOfDay(hour: 16, minute: 30),
|
||||
endTime: const TimeOfDay(hour: 8, minute: 0),
|
||||
),
|
||||
disabledColor: Colors.grey,
|
||||
paintingStyle: PaintingStyle.fill,
|
||||
interval: const Duration(minutes: 5),
|
||||
fromText: 'Beginnend',
|
||||
toText: 'Endend',
|
||||
strokeColor: Theme.of(context).colorScheme.secondary,
|
||||
minDuration: const Duration(minutes: 15),
|
||||
selectedColor: Theme.of(context).primaryColor,
|
||||
ticks: 24,
|
||||
);
|
||||
setState(() {
|
||||
_startTime = range.startTime;
|
||||
_endTime = range.endTime;
|
||||
});
|
||||
}
|
||||
|
||||
@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),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
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(
|
||||
headerEnabled: true,
|
||||
weekdayBackgroundColor: Theme.of(context).colorScheme.secondary,
|
||||
weekdaySelectedBackgroundColor: Theme.of(context).primaryColor,
|
||||
weekdayColor: Colors.black,
|
||||
),
|
||||
initialRRule: _rrule,
|
||||
textDelegate: const GermanRRuleTextDelegate(),
|
||||
onChange: (newValue) {
|
||||
log('Rule: $newValue');
|
||||
setState(() => _rrule = newValue);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Abbrechen')),
|
||||
TextButton(onPressed: _save, child: Text(_isEditing ? 'Speichern' : 'Erstellen')),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:jiffy/jiffy.dart';
|
||||
|
||||
import '../../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart';
|
||||
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
||||
import '../../../../widget/centeredLeading.dart';
|
||||
import '../../../../widget/placeholderView.dart';
|
||||
import '../details/delete_custom_event.dart';
|
||||
import 'custom_event_edit_dialog.dart';
|
||||
|
||||
class CustomEventsView extends StatelessWidget {
|
||||
const CustomEventsView({super.key});
|
||||
|
||||
void _openCreateDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => const CustomEventEditDialog(),
|
||||
barrierDismissible: false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
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'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView(
|
||||
children: events.map((e) => ListTile(
|
||||
title: Text(e.title),
|
||||
subtitle: Text(
|
||||
'${e.rrule.isNotEmpty ? "wiederholend, " : ""}'
|
||||
'beginnend ${Jiffy.parseFromDateTime(e.startDate).fromNow()}',
|
||||
),
|
||||
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(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart';
|
||||
import '../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart';
|
||||
|
||||
sealed class ArbitraryAppointment {
|
||||
const ArbitraryAppointment();
|
||||
|
||||
T when<T>({
|
||||
required T Function(GetTimetableResponseObject lesson) webuntis,
|
||||
required T Function(CustomTimetableEvent event) custom,
|
||||
}) => switch (this) {
|
||||
WebuntisAppointment(:final lesson) => webuntis(lesson),
|
||||
CustomAppointment(:final event) => custom(event),
|
||||
};
|
||||
}
|
||||
|
||||
class WebuntisAppointment extends ArbitraryAppointment {
|
||||
final GetTimetableResponseObject lesson;
|
||||
const WebuntisAppointment(this.lesson);
|
||||
}
|
||||
|
||||
class CustomAppointment extends ArbitraryAppointment {
|
||||
final CustomTimetableEvent event;
|
||||
const CustomAppointment(this.event);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'lesson_status.dart';
|
||||
|
||||
class LessonColor {
|
||||
static const Color cancelled = Color(0xff000000);
|
||||
static const Color irregular = Color(0xff8F19B3);
|
||||
static const Color teacherChanged = Color(0xFF29639B);
|
||||
static const Color parseFallback = Color(0xff404040);
|
||||
|
||||
static Color forStatus(LessonStatus status, ColorScheme scheme) {
|
||||
switch (status) {
|
||||
case LessonStatus.cancelled:
|
||||
return cancelled;
|
||||
case LessonStatus.irregular:
|
||||
return irregular;
|
||||
case LessonStatus.teacherChanged:
|
||||
return teacherChanged;
|
||||
case LessonStatus.past:
|
||||
case LessonStatus.regular:
|
||||
return scheme.primary;
|
||||
case LessonStatus.ongoing:
|
||||
return Color.from(
|
||||
alpha: scheme.primary.a,
|
||||
red: 200 / 255,
|
||||
green: scheme.primary.g,
|
||||
blue: scheme.primary.b,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import '../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart';
|
||||
|
||||
enum LessonStatus {
|
||||
cancelled,
|
||||
irregular,
|
||||
teacherChanged,
|
||||
past,
|
||||
ongoing,
|
||||
regular,
|
||||
}
|
||||
|
||||
class LessonStatusClassifier {
|
||||
static LessonStatus classify(GetTimetableResponseObject lesson, DateTime startTime, DateTime endTime, DateTime now) {
|
||||
if (lesson.code == 'cancelled') return LessonStatus.cancelled;
|
||||
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;
|
||||
return LessonStatus.regular;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
|
||||
import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart';
|
||||
import '../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart';
|
||||
import '../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart';
|
||||
import '../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart';
|
||||
import '../../../../storage/timetable/timetableSettings.dart';
|
||||
import '../../../../storage/timetable/timetable_name_mode.dart';
|
||||
import '../custom_events/custom_event_colors.dart';
|
||||
import 'arbitrary_appointment.dart';
|
||||
import 'lesson_color.dart';
|
||||
import 'lesson_status.dart';
|
||||
import 'webuntis_time.dart';
|
||||
|
||||
class TimetableAppointmentFactory {
|
||||
final List<GetTimetableResponseObject> lessons;
|
||||
final List<CustomTimetableEvent> customEvents;
|
||||
final GetRoomsResponse rooms;
|
||||
final GetSubjectsResponse subjects;
|
||||
final TimetableSettings settings;
|
||||
final ColorScheme colorScheme;
|
||||
final DateTime now;
|
||||
|
||||
TimetableAppointmentFactory({
|
||||
required this.lessons,
|
||||
required this.customEvents,
|
||||
required this.rooms,
|
||||
required this.subjects,
|
||||
required this.settings,
|
||||
required this.colorScheme,
|
||||
required this.now,
|
||||
});
|
||||
|
||||
List<Appointment> build() {
|
||||
final source = settings.connectDoubleLessons ? _mergeAdjacentLessons(lessons) : lessons;
|
||||
return [
|
||||
...source.map(_lessonToAppointment),
|
||||
...customEvents.map(_customEventToAppointment),
|
||||
];
|
||||
}
|
||||
|
||||
Appointment _lessonToAppointment(GetTimetableResponseObject lesson) {
|
||||
try {
|
||||
final startTime = WebuntisTime.parse(lesson.date, lesson.startTime);
|
||||
final endTime = WebuntisTime.parse(lesson.date, lesson.endTime);
|
||||
final status = LessonStatusClassifier.classify(lesson, startTime, endTime, now);
|
||||
|
||||
return Appointment(
|
||||
id: WebuntisAppointment(lesson),
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
subject: _subjectName(lesson),
|
||||
location: _locationLabel(lesson),
|
||||
notes: lesson.activityType,
|
||||
color: LessonColor.forStatus(status, colorScheme),
|
||||
);
|
||||
} catch (_) {
|
||||
return Appointment(
|
||||
id: WebuntisAppointment(lesson),
|
||||
startTime: WebuntisTime.parse(lesson.date, lesson.startTime),
|
||||
endTime: WebuntisTime.parse(lesson.date, lesson.endTime),
|
||||
subject: 'Änderung',
|
||||
notes: lesson.info,
|
||||
location: 'Unbekannt',
|
||||
color: LessonColor.parseFallback,
|
||||
startTimeZone: '',
|
||||
endTimeZone: '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Appointment _customEventToAppointment(CustomTimetableEvent event) => Appointment(
|
||||
id: CustomAppointment(event),
|
||||
startTime: event.startDate,
|
||||
endTime: event.endDate,
|
||||
location: event.description,
|
||||
subject: event.title,
|
||||
recurrenceRule: event.rrule,
|
||||
color: TimetableColors.getColorFromString(event.color ?? TimetableColors.defaultColor.name),
|
||||
startTimeZone: '',
|
||||
endTimeZone: '',
|
||||
);
|
||||
|
||||
String _subjectName(GetTimetableResponseObject lesson) {
|
||||
final subject = subjects.result.firstWhereOrNull((s) => s.id == lesson.su.firstOrNull?.id);
|
||||
if (subject == null) return 'Unbekannt';
|
||||
return switch (settings.timetableNameMode) {
|
||||
TimetableNameMode.name => subject.name,
|
||||
TimetableNameMode.longName => subject.longName,
|
||||
TimetableNameMode.alternateName => subject.alternateName,
|
||||
};
|
||||
}
|
||||
|
||||
String _locationLabel(GetTimetableResponseObject lesson) {
|
||||
final roomName = rooms.result.firstWhereOrNull((r) => r.id == lesson.ro.firstOrNull?.id)?.name ?? 'Unbekannt';
|
||||
final teacherName = lesson.te.firstOrNull?.longname ?? 'Unbekannt';
|
||||
return '$roomName\n$teacherName';
|
||||
}
|
||||
|
||||
// Pure: returns a new list, does not mutate input.
|
||||
static List<GetTimetableResponseObject> _mergeAdjacentLessons(
|
||||
List<GetTimetableResponseObject> input, {
|
||||
Duration maxGap = const Duration(minutes: 5),
|
||||
}) {
|
||||
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 merged = <GetTimetableResponseObject>[sorted.first];
|
||||
for (var i = 1; i < sorted.length; i++) {
|
||||
final previous = merged.last;
|
||||
final current = sorted[i];
|
||||
if (_canMerge(previous, current, maxGap)) {
|
||||
previous.endTime = current.endTime;
|
||||
} else {
|
||||
merged.add(current);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
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 (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;
|
||||
|
||||
final gap = WebuntisTime.parse(b.date, b.startTime).difference(WebuntisTime.parse(a.date, a.endTime));
|
||||
return gap <= maxGap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class WebuntisTime {
|
||||
static final DateFormat _dateFormat = DateFormat('yyyyMMdd');
|
||||
|
||||
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)}');
|
||||
}
|
||||
|
||||
static int formatDate(DateTime date) => int.parse(_dateFormat.format(date));
|
||||
|
||||
static String dateKey(DateTime date) => _dateFormat.format(date);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
void showAppointmentBottomSheet(
|
||||
BuildContext context, {
|
||||
required Widget Function(BuildContext context) header,
|
||||
required SliverChildListDelegate Function(BuildContext context) body,
|
||||
}) {
|
||||
showStickyFlexibleBottomSheet(
|
||||
minHeight: 0,
|
||||
initHeight: 0.4,
|
||||
maxHeight: 0.7,
|
||||
anchors: [0, 0.4, 0.7],
|
||||
isSafeArea: true,
|
||||
maxHeaderHeight: 100,
|
||||
context: context,
|
||||
headerBuilder: (context, _) => header(context),
|
||||
bodyBuilder: (context, _) => body(context),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
|
||||
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||
import '../data/arbitrary_appointment.dart';
|
||||
import 'custom_event_sheet.dart';
|
||||
import 'webuntis_lesson_sheet.dart';
|
||||
|
||||
class AppointmentDetailsDispatcher {
|
||||
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),
|
||||
custom: (event) => CustomEventSheet.show(context, event),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:jiffy/jiffy.dart';
|
||||
import 'package:rrule/rrule.dart';
|
||||
|
||||
import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart';
|
||||
import '../../../../widget/centeredLeading.dart';
|
||||
import '../../../../widget/debug/debugTile.dart';
|
||||
import '../custom_events/custom_event_edit_dialog.dart';
|
||||
import '_bottom_sheet.dart';
|
||||
import 'delete_custom_event.dart';
|
||||
|
||||
class CustomEventSheet {
|
||||
static void show(BuildContext context, CustomTimetableEvent event) {
|
||||
showAppointmentBottomSheet(
|
||||
context,
|
||||
header: (_) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(event.title, style: const TextStyle(fontSize: 25, overflow: TextOverflow.ellipsis)),
|
||||
Text(
|
||||
'${Jiffy.parseFromDateTime(event.startDate).format(pattern: 'HH:mm')} - '
|
||||
'${Jiffy.parseFromDateTime(event.endDate).format(pattern: 'HH:mm')}',
|
||||
style: const TextStyle(fontSize: 15),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: (sheetCtx) => SliverChildListDelegate([
|
||||
const Divider(),
|
||||
Center(
|
||||
child: Wrap(
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(sheetCtx).pop();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => CustomEventEditDialog(existingEvent: event),
|
||||
);
|
||||
},
|
||||
label: const Text('Bearbeiten'),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
showDeleteCustomEventDialog(context, event).future.then((_) {
|
||||
if (!sheetCtx.mounted) return;
|
||||
Navigator.of(sheetCtx).pop();
|
||||
});
|
||||
},
|
||||
label: const Text('Löschen'),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
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"}'),
|
||||
subtitle: FutureBuilder(
|
||||
future: RruleL10nEn.create(),
|
||||
builder: (_, snapshot) {
|
||||
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.');
|
||||
return Text(rrule.toText(l10n: snapshot.data!));
|
||||
},
|
||||
),
|
||||
),
|
||||
DebugTile(sheetCtx).child(
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.rule)),
|
||||
title: const Text('RRule'),
|
||||
subtitle: Text(event.rrule.isEmpty ? 'Keine' : event.rrule),
|
||||
),
|
||||
),
|
||||
DebugTile(sheetCtx).jsonData(event.toJson()),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart';
|
||||
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||
import '../../../../widget/confirmDialog.dart';
|
||||
|
||||
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.',
|
||||
confirmButton: 'Löschen',
|
||||
onConfirm: () {
|
||||
bloc.removeCustomEvent(event.id).then(completer.complete).onError((Object error, StackTrace stack) {
|
||||
completer.completeError(error, stack);
|
||||
});
|
||||
},
|
||||
).asDialog(context);
|
||||
return completer;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:jiffy/jiffy.dart';
|
||||
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
|
||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
|
||||
import '../../../../api/webuntis/queries/getRooms/getRoomsResponse.dart';
|
||||
import '../../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart';
|
||||
import '../../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart';
|
||||
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
||||
import '../../../../widget/debug/debugTile.dart';
|
||||
import '../../../../widget/unimplementedDialog.dart';
|
||||
import '../../more/roomplan/roomplan.dart';
|
||||
import '_bottom_sheet.dart';
|
||||
|
||||
class WebuntisLessonSheet {
|
||||
static void show(BuildContext context, TimetableBloc bloc, Appointment appointment, GetTimetableResponseObject lesson) {
|
||||
final state = bloc.state.data;
|
||||
if (state == null) return;
|
||||
|
||||
final subject = _resolveSubject(state, lesson);
|
||||
final room = _resolveRoom(state, lesson);
|
||||
|
||||
showAppointmentBottomSheet(
|
||||
context,
|
||||
header: (_) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${_codePrefix(lesson.code)}${subject.alternateName}',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 25),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(subject.longName),
|
||||
Text(
|
||||
'${Jiffy.parseFromDateTime(appointment.startTime).format(pattern: 'HH:mm')} - '
|
||||
'${Jiffy.parseFromDateTime(appointment.endTime).format(pattern: 'HH:mm')}',
|
||||
style: const TextStyle(fontSize: 15),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: (_) => SliverChildListDelegate([
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.notifications_active),
|
||||
title: Text('Status: ${lesson.code != null ? "Geändert" : "Regulär"}'),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.room),
|
||||
title: Text('Raum: ${room.name} (${room.longName})'),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.house_outlined),
|
||||
onPressed: () => pushScreen(context, withNavBar: false, screen: const Roomplan()),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person),
|
||||
title: lesson.te.isNotEmpty
|
||||
? Text(
|
||||
'Lehrkraft: ${lesson.te[0].name}'
|
||||
'${lesson.te[0].longname.isNotEmpty ? " (${lesson.te[0].longname})" : ""}',
|
||||
)
|
||||
: const Text('?'),
|
||||
trailing: Visibility(
|
||||
visible: !kReleaseMode,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.textsms_outlined),
|
||||
onPressed: () => UnimplementedDialog.show(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.abc),
|
||||
title: Text('Typ: ${lesson.activityType}'),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.people),
|
||||
title: Text('Klasse(n): ${lesson.kl.map((e) => e.name).join(", ")}'),
|
||||
),
|
||||
DebugTile(context).jsonData(lesson.toJson()),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
static String _codePrefix(String? code) {
|
||||
if (code == 'cancelled') return 'Entfällt: ';
|
||||
if (code == 'irregular') return 'Änderung: ';
|
||||
return code ?? '';
|
||||
}
|
||||
|
||||
static GetSubjectsResponseObject _resolveSubject(TimetableState state, GetTimetableResponseObject lesson) {
|
||||
try {
|
||||
return state.subjects!.result.firstWhere((s) => s.id == lesson.su[0].id);
|
||||
} catch (_) {
|
||||
return GetSubjectsResponseObject(0, '?', 'Unbekannt', '?', true);
|
||||
}
|
||||
}
|
||||
|
||||
static GetRoomsResponseObject _resolveRoom(TimetableState state, GetTimetableResponseObject lesson) {
|
||||
try {
|
||||
return state.rooms!.result.firstWhere((r) => r.id == lesson.ro[0].id);
|
||||
} catch (_) {
|
||||
return GetRoomsResponseObject(0, '?', 'Unbekannt', true, '?');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,25 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../extensions/dateTime.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
|
||||
import '../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart';
|
||||
import '../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart';
|
||||
import '../../../model/timetable/timetableProps.dart';
|
||||
import '../../../storage/base/settingsProvider.dart';
|
||||
import '../../../widget/loadingSpinner.dart';
|
||||
import '../../../widget/placeholderView.dart';
|
||||
import 'appointmenetComponent.dart';
|
||||
import 'appointmentDetails.dart';
|
||||
import 'arbitraryAppointment.dart';
|
||||
import 'customTimetableColors.dart';
|
||||
import 'customTimetableEventEditDialog.dart';
|
||||
import 'timeRegionComponent.dart';
|
||||
import 'timetableEvents.dart';
|
||||
import 'timetableNameMode.dart';
|
||||
import 'viewCustomTimetableEvents.dart';
|
||||
import '../../../extensions/dateTime.dart';
|
||||
import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart';
|
||||
import '../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||
import '../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import 'custom_events/custom_event_edit_dialog.dart';
|
||||
import 'custom_events/custom_events_view.dart';
|
||||
import 'data/arbitrary_appointment.dart';
|
||||
import 'data/timetable_appointment_factory.dart';
|
||||
import 'details/appointment_details_dispatcher.dart';
|
||||
import 'widgets/appointment_tile.dart';
|
||||
import 'widgets/lesson_appointment_source.dart';
|
||||
import 'widgets/special_regions_builder.dart';
|
||||
import 'widgets/time_region_tile.dart';
|
||||
|
||||
enum _CalendarAction { addEvent, viewEvents }
|
||||
|
||||
class Timetable extends StatefulWidget {
|
||||
const Timetable({super.key});
|
||||
@@ -29,362 +28,152 @@ class Timetable extends StatefulWidget {
|
||||
State<Timetable> createState() => _TimetableState();
|
||||
}
|
||||
|
||||
enum CalendarActions { addEvent, viewEvents }
|
||||
|
||||
class _TimetableState extends State<Timetable> {
|
||||
CalendarController controller = CalendarController();
|
||||
late Timer updateTimings;
|
||||
late final SettingsProvider settings;
|
||||
final CalendarController _controller = CalendarController();
|
||||
late Timer _highlightTicker;
|
||||
|
||||
LessonAppointmentSource? _cachedSource;
|
||||
int? _lastDataVersion;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
settings = Provider.of<SettingsProvider>(context, listen: false);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||
Provider.of<TimetableProps>(context, listen: false).run();
|
||||
});
|
||||
|
||||
controller.displayDate = DateTime.now().add(const Duration(days: 2));
|
||||
|
||||
updateTimings = Timer.periodic(const Duration(seconds: 30), (Timer t) => setState((){}));
|
||||
|
||||
super.initState();
|
||||
_controller.displayDate = _initialDisplayDate();
|
||||
|
||||
_highlightTicker = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||
if (mounted) setState(() => _cachedSource = null);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Stunden & Vertretungsplan'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.home_outlined),
|
||||
onPressed: () {
|
||||
controller.displayDate = DateTime.now().add(const Duration(days: 2));
|
||||
}
|
||||
),
|
||||
PopupMenuButton<CalendarActions>(
|
||||
icon: const Icon(Icons.edit_calendar_outlined),
|
||||
itemBuilder: (context) => CalendarActions.values.map(
|
||||
(e) {
|
||||
String title;
|
||||
Icon icon;
|
||||
switch(e) {
|
||||
case CalendarActions.addEvent:
|
||||
title = 'Kalendereintrag hinzufügen';
|
||||
icon = const Icon(Icons.add);
|
||||
case CalendarActions.viewEvents:
|
||||
title = 'Kalendereinträge anzeigen';
|
||||
icon = const Icon(Icons.perm_contact_calendar_outlined);
|
||||
}
|
||||
return PopupMenuItem<CalendarActions>(
|
||||
value: e,
|
||||
child: ListTile(
|
||||
title: Text(title),
|
||||
leading: icon,
|
||||
)
|
||||
);
|
||||
}
|
||||
).toList(),
|
||||
onSelected: (value) {
|
||||
switch(value) {
|
||||
case CalendarActions.addEvent:
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => const CustomTimetableEventEditDialog(),
|
||||
barrierDismissible: false,
|
||||
);
|
||||
case CalendarActions.viewEvents:
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (context) => const ViewCustomTimetableEvents()));
|
||||
}
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
body: Consumer<TimetableProps>(
|
||||
builder: (context, value, child) {
|
||||
|
||||
if(value.hasError) {
|
||||
return PlaceholderView(
|
||||
icon: Icons.calendar_month,
|
||||
text: 'Webuntis error: ${value.error.toString()}',
|
||||
button: TextButton(
|
||||
child: const Text('Neu laden'),
|
||||
onPressed: () {
|
||||
controller.displayDate = DateTime.now().add(const Duration(days: 2));
|
||||
Provider.of<TimetableProps>(context, listen: false).resetWeek();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if(value.primaryLoading()) return const LoadingSpinner();
|
||||
|
||||
var holidays = value.getHolidaysResponse;
|
||||
|
||||
return RefreshIndicator(
|
||||
child: SfCalendar(
|
||||
timeZone: 'W. Europe Standard Time',
|
||||
view: CalendarView.workWeek,
|
||||
dataSource: _buildTableEvents(value),
|
||||
|
||||
maxDate: DateTime.now().add(const Duration(days: 7)).nextWeekday(DateTime.saturday),
|
||||
minDate: DateTime.now().subtract(const Duration (days: 14)).nextWeekday(DateTime.sunday),
|
||||
|
||||
controller: controller,
|
||||
|
||||
onViewChanged: (ViewChangedDetails details) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||
Provider.of<TimetableProps>(context, listen: false).updateWeek(details.visibleDates.first, details.visibleDates.last);
|
||||
});
|
||||
},
|
||||
|
||||
onTap: (calendarTapDetails) {
|
||||
if(calendarTapDetails.appointments == null) return;
|
||||
Appointment tapped = calendarTapDetails.appointments!.first;
|
||||
AppointmentDetails.show(context, value, tapped);
|
||||
},
|
||||
|
||||
firstDayOfWeek: DateTime.monday,
|
||||
specialRegions: _buildSpecialTimeRegions(holidays),
|
||||
timeSlotViewSettings: const TimeSlotViewSettings(
|
||||
startHour: 07.5,
|
||||
endHour: 16.5,
|
||||
timeInterval: Duration(minutes: 30),
|
||||
timeFormat: 'HH:mm',
|
||||
dayFormat: 'EE',
|
||||
timeIntervalHeight: 40,
|
||||
),
|
||||
|
||||
timeRegionBuilder: (BuildContext context, TimeRegionDetails timeRegionDetails) => TimeRegionComponent(details: timeRegionDetails),
|
||||
appointmentBuilder: (BuildContext context, CalendarAppointmentDetails details) => AppointmentComponent(
|
||||
details: details,
|
||||
crossedOut: _isCrossedOut(details)
|
||||
),
|
||||
|
||||
headerHeight: 0,
|
||||
selectionDecoration: const BoxDecoration(),
|
||||
|
||||
allowAppointmentResize: false,
|
||||
allowDragAndDrop: false,
|
||||
allowViewNavigation: false,
|
||||
),
|
||||
onRefresh: () async {
|
||||
Provider.of<TimetableProps>(context, listen: false).run(renew: true);
|
||||
return Future.delayed(const Duration(seconds: 3));
|
||||
}
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
updateTimings.cancel();
|
||||
_highlightTicker.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<TimeRegion> _buildSpecialTimeRegions(GetHolidaysResponse holidays) {
|
||||
var lastMonday = DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.monday);
|
||||
var firstBreak = lastMonday.copyWith(hour: 10, minute: 15);
|
||||
var secondBreak = lastMonday.copyWith(hour: 13, minute: 50);
|
||||
DateTime _initialDisplayDate() => DateTime.now().add(const Duration(days: 2));
|
||||
|
||||
var holidayList = holidays.result.map((holiday) {
|
||||
var startDay = _parseWebuntisTimestamp(holiday.startDate, 0);
|
||||
var dayCount = _parseWebuntisTimestamp(holiday.endDate, 0)
|
||||
.difference(startDay)
|
||||
.inDays;
|
||||
var days = List<DateTime>.generate(dayCount, (index) => startDay.add(Duration(days: index)));
|
||||
|
||||
return days.map((holidayDay) => TimeRegion(
|
||||
startTime: holidayDay.copyWith(hour: 07, minute: 55),
|
||||
endTime: holidayDay.copyWith(hour: 16, minute: 30),
|
||||
text: 'holiday:${holiday.name}',
|
||||
color: Theme
|
||||
.of(context)
|
||||
.disabledColor
|
||||
.withAlpha(50),
|
||||
iconData: Icons.holiday_village_outlined
|
||||
));
|
||||
}).expand((e) => e);
|
||||
|
||||
bool isInHoliday(DateTime time) => holidayList.any((element) => element.startTime.isSameDay(time));
|
||||
|
||||
return [
|
||||
...holidayList,
|
||||
|
||||
if(!isInHoliday(firstBreak))
|
||||
TimeRegion(
|
||||
startTime: firstBreak,
|
||||
endTime: firstBreak.add(const Duration(minutes: 20)),
|
||||
recurrenceRule: 'FREQ=DAILY;INTERVAL=1',
|
||||
text: 'centerIcon',
|
||||
color: Theme.of(context).primaryColor.withAlpha(50),
|
||||
iconData: Icons.restaurant
|
||||
),
|
||||
|
||||
if(!isInHoliday(secondBreak))
|
||||
TimeRegion(
|
||||
startTime: secondBreak,
|
||||
endTime: secondBreak.add(const Duration(minutes: 15)),
|
||||
recurrenceRule: 'FREQ=DAILY;INTERVAL=1',
|
||||
text: 'centerIcon',
|
||||
color: Theme.of(context).primaryColor.withAlpha(50),
|
||||
iconData: Icons.restaurant
|
||||
),
|
||||
];
|
||||
void _jumpToToday() {
|
||||
_controller.displayDate = _initialDisplayDate();
|
||||
}
|
||||
|
||||
List<GetTimetableResponseObject> _removeDuplicates(TimetableProps data, Duration maxTimeBetweenDouble) {
|
||||
|
||||
var timetableList = data.getTimetableResponse.result.toList();
|
||||
|
||||
if(timetableList.isEmpty) return timetableList;
|
||||
|
||||
timetableList.sort((a, b) => _parseWebuntisTimestamp(a.date, a.startTime).compareTo(_parseWebuntisTimestamp(b.date, b.startTime)));
|
||||
|
||||
var previousElement = timetableList.first;
|
||||
for(var i = 1; i < timetableList.length; i++) {
|
||||
var currentElement = timetableList.elementAt(i);
|
||||
|
||||
bool isSameLesson() {
|
||||
var currentSubjectId = currentElement.su.firstOrNull?.id;
|
||||
var previousSubjectId = previousElement.su.firstOrNull?.id;
|
||||
|
||||
if(currentSubjectId == null || previousSubjectId == null || currentSubjectId != previousSubjectId) return false;
|
||||
|
||||
var currentRoomId = currentElement.ro.firstOrNull?.id;
|
||||
var previousRoomId = previousElement.ro.firstOrNull?.id;
|
||||
|
||||
if(currentRoomId != previousRoomId) return false;
|
||||
|
||||
var currentTeacherId = currentElement.te.firstOrNull?.id;
|
||||
var previousTeacherId = previousElement.te.firstOrNull?.id;
|
||||
|
||||
if(currentTeacherId != previousTeacherId) return false;
|
||||
|
||||
var currentStatusCode = currentElement.code;
|
||||
var previousStatusCode = previousElement.code;
|
||||
|
||||
if(currentStatusCode != previousStatusCode) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool isNotSeparated() => _parseWebuntisTimestamp(previousElement.date, previousElement.endTime).add(maxTimeBetweenDouble)
|
||||
.isSameOrAfter(_parseWebuntisTimestamp(currentElement.date, currentElement.startTime));
|
||||
|
||||
if(isSameLesson() && isNotSeparated()) {
|
||||
previousElement.endTime = currentElement.endTime;
|
||||
timetableList.remove(currentElement);
|
||||
i--;
|
||||
} else {
|
||||
previousElement = currentElement;
|
||||
}
|
||||
}
|
||||
|
||||
return timetableList;
|
||||
}
|
||||
|
||||
TimetableEvents _buildTableEvents(TimetableProps data) {
|
||||
|
||||
var timetableList = data.getTimetableResponse.result.toList();
|
||||
|
||||
if(settings.val().timetableSettings.connectDoubleLessons) {
|
||||
timetableList = _removeDuplicates(data, const Duration(minutes: 5));
|
||||
}
|
||||
|
||||
var appointments = timetableList.map((element) {
|
||||
|
||||
var rooms = data.getRoomsResponse;
|
||||
var subjects = data.getSubjectsResponse;
|
||||
|
||||
try {
|
||||
var startTime = _parseWebuntisTimestamp(element.date, element.startTime);
|
||||
var endTime = _parseWebuntisTimestamp(element.date, element.endTime);
|
||||
|
||||
var subject = subjects.result.firstWhereOrNull((subject) => subject.id == element.su.firstOrNull?.id);
|
||||
var subjectName = 'Unbekannt';
|
||||
if(subject != null) {
|
||||
subjectName = {
|
||||
TimetableNameMode.name: subject.name,
|
||||
TimetableNameMode.longName: subject.longName,
|
||||
TimetableNameMode.alternateName: subject.alternateName,
|
||||
}[settings.val().timetableSettings.timetableNameMode]!;
|
||||
}
|
||||
|
||||
return Appointment(
|
||||
id: ArbitraryAppointment(webuntis: element),
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
subject: subjectName,
|
||||
location: ''
|
||||
'${rooms.result.firstWhereOrNull((room) => room.id == element.ro.firstOrNull?.id)?.name ?? 'Unbekannt'}'
|
||||
'\n'
|
||||
'${element.te.firstOrNull?.longname ?? 'Unbekannt'}',
|
||||
notes: element.activityType,
|
||||
color: _getEventColor(element, startTime, endTime),
|
||||
void _onAction(_CalendarAction action) {
|
||||
switch (action) {
|
||||
case _CalendarAction.addEvent:
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => const CustomEventEditDialog(),
|
||||
barrierDismissible: false,
|
||||
);
|
||||
} catch(e) {
|
||||
var endTime = _parseWebuntisTimestamp(element.date, element.endTime);
|
||||
return Appointment(
|
||||
id: ArbitraryAppointment(webuntis: element),
|
||||
startTime: _parseWebuntisTimestamp(element.date, element.startTime),
|
||||
endTime: endTime,
|
||||
subject: 'Änderung',
|
||||
notes: element.info,
|
||||
location: 'Unbekannt',
|
||||
color: const Color(0xff404040),
|
||||
startTimeZone: '',
|
||||
endTimeZone: '',
|
||||
);
|
||||
}
|
||||
}).toList();
|
||||
|
||||
appointments.addAll(data.getCustomTimetableEventResponse.events.map((customEvent) => Appointment(
|
||||
id: ArbitraryAppointment(custom: customEvent),
|
||||
startTime: customEvent.startDate,
|
||||
endTime: customEvent.endDate,
|
||||
location: customEvent.description,
|
||||
subject: customEvent.title,
|
||||
recurrenceRule: customEvent.rrule,
|
||||
color: TimetableColors.getColorFromString(customEvent.color ?? TimetableColors.defaultColor.name),
|
||||
startTimeZone: '',
|
||||
endTimeZone: '',
|
||||
)));
|
||||
|
||||
return TimetableEvents(appointments);
|
||||
}
|
||||
|
||||
DateTime _parseWebuntisTimestamp(int date, int time) {
|
||||
var timeString = time.toString().padLeft(4, '0');
|
||||
return DateTime.parse('$date ${timeString.substring(0, 2)}:${timeString.substring(2, 4)}');
|
||||
}
|
||||
|
||||
Color _getEventColor(GetTimetableResponseObject webuntisElement, DateTime startTime, DateTime endTime) {
|
||||
// Cancelled
|
||||
if(webuntisElement.code == 'cancelled') return const Color(0xff000000);
|
||||
|
||||
// Any changes or no teacher at this element
|
||||
if(webuntisElement.code == 'irregular' || webuntisElement.te.first.id == 0) return const Color(0xff8F19B3);
|
||||
|
||||
// Teacher has changed
|
||||
if(webuntisElement.te.any((element) => element.orgname != null)) return const Color(0xFF29639B);
|
||||
|
||||
// Event was in the past
|
||||
if(endTime.isBefore(DateTime.now())) return Theme.of(context).primaryColor;
|
||||
|
||||
// Event takes currently place
|
||||
if(endTime.isAfter(DateTime.now()) && startTime.isBefore(DateTime.now())) return Theme.of(context).primaryColor.withRed(200);
|
||||
|
||||
// Fallback
|
||||
return Theme.of(context).primaryColor;
|
||||
}
|
||||
|
||||
bool _isCrossedOut(CalendarAppointmentDetails calendarEntry) {
|
||||
var appointment = calendarEntry.appointments.first.id as ArbitraryAppointment;
|
||||
if(appointment.hasWebuntis()) {
|
||||
return appointment.webuntis!.code == 'cancelled';
|
||||
case _CalendarAction.viewEvents:
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const CustomEventsView()));
|
||||
}
|
||||
}
|
||||
|
||||
LessonAppointmentSource _appointmentSource(TimetableState state) {
|
||||
if (_cachedSource != null && _lastDataVersion == state.dataVersion) {
|
||||
return _cachedSource!;
|
||||
}
|
||||
_lastDataVersion = state.dataVersion;
|
||||
|
||||
final settings = context.read<SettingsCubit>();
|
||||
final appointments = TimetableAppointmentFactory(
|
||||
lessons: state.getAllKnownLessons().toList(),
|
||||
customEvents: state.customEvents?.events ?? const [],
|
||||
rooms: state.rooms!,
|
||||
subjects: state.subjects!,
|
||||
settings: settings.val().timetableSettings,
|
||||
colorScheme: Theme.of(context).colorScheme,
|
||||
now: DateTime.now(),
|
||||
).build();
|
||||
|
||||
return _cachedSource = LessonAppointmentSource(appointments);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bloc = context.read<TimetableBloc>();
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Stunden & Vertretungsplan'),
|
||||
actions: [
|
||||
IconButton(icon: const Icon(Icons.home_outlined), onPressed: _jumpToToday),
|
||||
PopupMenuButton<_CalendarAction>(
|
||||
icon: const Icon(Icons.edit_calendar_outlined),
|
||||
onSelected: _onAction,
|
||||
itemBuilder: (_) => const [
|
||||
PopupMenuItem(
|
||||
value: _CalendarAction.addEvent,
|
||||
child: ListTile(title: Text('Kalendereintrag hinzufügen'), leading: Icon(Icons.add)),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _CalendarAction.viewEvents,
|
||||
child: ListTile(
|
||||
title: Text('Kalendereinträge anzeigen'),
|
||||
leading: Icon(Icons.perm_contact_calendar_outlined),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: LoadableStateConsumer<TimetableBloc, TimetableState>(
|
||||
child: (state, _) => _calendar(state, bloc),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _calendar(TimetableState state, TimetableBloc bloc) {
|
||||
if (!state.hasReferenceData) return const SizedBox.shrink();
|
||||
|
||||
return SfCalendar(
|
||||
timeZone: 'W. Europe Standard Time',
|
||||
view: CalendarView.workWeek,
|
||||
dataSource: _appointmentSource(state),
|
||||
maxDate: DateTime.now().add(const Duration(days: 7)).nextWeekday(DateTime.saturday),
|
||||
minDate: DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.sunday),
|
||||
controller: _controller,
|
||||
onViewChanged: (details) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
bloc.changeWeek(details.visibleDates.first, details.visibleDates.last);
|
||||
});
|
||||
},
|
||||
onTap: (tap) {
|
||||
if (tap.appointments == null || tap.appointments!.isEmpty) return;
|
||||
AppointmentDetailsDispatcher.show(context, bloc, tap.appointments!.first);
|
||||
},
|
||||
firstDayOfWeek: DateTime.monday,
|
||||
specialRegions: SpecialRegionsBuilder(
|
||||
holidays: state.schoolHolidays!,
|
||||
colorScheme: Theme.of(context).colorScheme,
|
||||
disabledColor: Theme.of(context).disabledColor,
|
||||
).build(),
|
||||
timeSlotViewSettings: const TimeSlotViewSettings(
|
||||
startHour: 7.5,
|
||||
endHour: 16.5,
|
||||
timeInterval: Duration(minutes: 30),
|
||||
timeFormat: 'HH:mm',
|
||||
dayFormat: 'EE',
|
||||
timeIntervalHeight: 40,
|
||||
),
|
||||
timeRegionBuilder: (_, details) => TimeRegionTile(details: details),
|
||||
appointmentBuilder: (_, details) => AppointmentTile(
|
||||
details: details,
|
||||
crossedOut: _isCrossedOut(details),
|
||||
),
|
||||
headerHeight: 0,
|
||||
selectionDecoration: const BoxDecoration(),
|
||||
allowAppointmentResize: false,
|
||||
allowDragAndDrop: false,
|
||||
allowViewNavigation: false,
|
||||
);
|
||||
}
|
||||
|
||||
bool _isCrossedOut(CalendarAppointmentDetails details) {
|
||||
final appointment = details.appointments.first;
|
||||
final id = appointment.id;
|
||||
if (id is WebuntisAppointment) return id.lesson.code == 'cancelled';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
|
||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
|
||||
class TimetableEvents extends CalendarDataSource {
|
||||
TimetableEvents(List<Appointment> source) {
|
||||
appointments = source;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../widget/dropdownDisplay.dart';
|
||||
|
||||
enum TimetableNameMode {
|
||||
name,
|
||||
longName,
|
||||
alternateName
|
||||
}
|
||||
|
||||
class TimetableNameModes {
|
||||
static DropdownDisplay getDisplayOptions(TimetableNameMode theme) {
|
||||
switch(theme) {
|
||||
case TimetableNameMode.name:
|
||||
return DropdownDisplay(icon: Icons.device_unknown_outlined, displayName: 'Name');
|
||||
|
||||
case TimetableNameMode.longName:
|
||||
return DropdownDisplay(icon: Icons.perm_device_info_outlined, displayName: 'Langname');
|
||||
|
||||
case TimetableNameMode.alternateName:
|
||||
return DropdownDisplay(icon: Icons.on_device_training_outlined, displayName: 'Kurzform');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:jiffy/jiffy.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.dart';
|
||||
import '../../../model/timetable/timetableProps.dart';
|
||||
import '../../../widget/centeredLeading.dart';
|
||||
import '../../../widget/loadingSpinner.dart';
|
||||
import '../../../widget/placeholderView.dart';
|
||||
import 'appointmentDetails.dart';
|
||||
import 'customTimetableEventEditDialog.dart';
|
||||
|
||||
class ViewCustomTimetableEvents extends StatefulWidget {
|
||||
const ViewCustomTimetableEvents({super.key});
|
||||
|
||||
@override
|
||||
State<ViewCustomTimetableEvents> createState() => _ViewCustomTimetableEventsState();
|
||||
}
|
||||
|
||||
class _ViewCustomTimetableEventsState extends State<ViewCustomTimetableEvents> {
|
||||
late Future<GetCustomTimetableEventResponse> events;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
_openCreateDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => const CustomTimetableEventEditDialog(),
|
||||
barrierDismissible: false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Eigene Termine'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: _openCreateDialog,
|
||||
)
|
||||
],
|
||||
),
|
||||
body: Consumer<TimetableProps>(builder: (context, value, child) {
|
||||
if(value.primaryLoading()) return const LoadingSpinner();
|
||||
|
||||
var listView = ListView(
|
||||
children: value.getCustomTimetableEventResponse.events.map((e) => ListTile(
|
||||
title: Text(e.title),
|
||||
subtitle: Text("${e.rrule.isNotEmpty ? "wiederholdend, " : ""}beginnend ${Jiffy.parseFromDateTime(e.startDate).fromNow()}"),
|
||||
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: (context) => CustomTimetableEventEditDialog(existingEvent: e));
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: () {
|
||||
AppointmentDetails.deleteCustomEvent(context, e);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
)).toList(),
|
||||
);
|
||||
|
||||
var placeholder = PlaceholderView(
|
||||
icon: Icons.calendar_today_outlined,
|
||||
text: 'Keine Einträge vorhanden',
|
||||
button: TextButton(
|
||||
onPressed: _openCreateDialog,
|
||||
child: const Text('Termin erstellen'),
|
||||
),
|
||||
);
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () {
|
||||
Provider.of<TimetableProps>(context, listen: false).run(renew: true);
|
||||
return Future.delayed(const Duration(seconds: 3));
|
||||
},
|
||||
child: value.getCustomTimetableEventResponse.events.isEmpty
|
||||
? placeholder
|
||||
: listView
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
|
||||
import 'cross_painter.dart';
|
||||
|
||||
class AppointmentTile extends StatelessWidget {
|
||||
final CalendarAppointmentDetails details;
|
||||
final bool crossedOut;
|
||||
|
||||
const AppointmentTile({super.key, required this.details, this.crossedOut = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Appointment meeting = details.appointments.first;
|
||||
final isPast = meeting.endTime.isBefore(DateTime.now());
|
||||
final color = meeting.color.withAlpha(isPast ? 100 : 255);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(3),
|
||||
height: details.bounds.height,
|
||||
alignment: Alignment.topLeft,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.rectangle,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(5)),
|
||||
color: color,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
FittedBox(
|
||||
fit: BoxFit.fitWidth,
|
||||
child: Text(
|
||||
meeting.subject,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500),
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
),
|
||||
),
|
||||
FittedBox(
|
||||
fit: BoxFit.fitWidth,
|
||||
child: Text(
|
||||
meeting.location?.isNotEmpty == true ? meeting.location! : ' ',
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 10),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (crossedOut)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(width: 2, color: Colors.red.withAlpha(200)),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(5)),
|
||||
),
|
||||
child: CustomPaint(painter: CrossPainter()),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
|
||||
class LessonAppointmentSource extends CalendarDataSource {
|
||||
LessonAppointmentSource(List<Appointment> source) {
|
||||
appointments = source;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
|
||||
import '../../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart';
|
||||
import '../../../../extensions/dateTime.dart';
|
||||
import '../data/webuntis_time.dart';
|
||||
import 'time_region_tile.dart';
|
||||
|
||||
class SpecialRegionsBuilder {
|
||||
final GetHolidaysResponse holidays;
|
||||
final ColorScheme colorScheme;
|
||||
final Color disabledColor;
|
||||
|
||||
SpecialRegionsBuilder({
|
||||
required this.holidays,
|
||||
required this.colorScheme,
|
||||
required this.disabledColor,
|
||||
});
|
||||
|
||||
List<TimeRegion> build() {
|
||||
final lastMonday = DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.monday);
|
||||
final firstBreak = lastMonday.copyWith(hour: 10, minute: 15);
|
||||
final secondBreak = lastMonday.copyWith(hour: 13, minute: 50);
|
||||
|
||||
final holidayRegions = _buildHolidayRegions().toList();
|
||||
bool isInHoliday(DateTime time) => holidayRegions.any((region) => region.startTime.isSameDay(time));
|
||||
|
||||
return [
|
||||
...holidayRegions,
|
||||
if (!isInHoliday(firstBreak)) _breakRegion(firstBreak, const Duration(minutes: 20)),
|
||||
if (!isInHoliday(secondBreak)) _breakRegion(secondBreak, const Duration(minutes: 15)),
|
||||
];
|
||||
}
|
||||
|
||||
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)));
|
||||
return days.map((day) => TimeRegion(
|
||||
startTime: day.copyWith(hour: 7, minute: 55),
|
||||
endTime: day.copyWith(hour: 16, minute: 30),
|
||||
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,
|
||||
);
|
||||
}
|
||||
+13
-16
@@ -1,31 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
|
||||
class TimeRegionComponent extends StatefulWidget {
|
||||
const String kTimeRegionCenterIcon = 'centerIcon';
|
||||
const String kTimeRegionHolidayPrefix = 'holiday:';
|
||||
|
||||
class TimeRegionTile extends StatelessWidget {
|
||||
final TimeRegionDetails details;
|
||||
const TimeRegionComponent({super.key, required this.details});
|
||||
|
||||
@override
|
||||
State<TimeRegionComponent> createState() => _TimeRegionComponentState();
|
||||
}
|
||||
const TimeRegionTile({super.key, required this.details});
|
||||
|
||||
class _TimeRegionComponentState extends State<TimeRegionComponent> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var text = widget.details.region.text!;
|
||||
var color = widget.details.region.color;
|
||||
final text = details.region.text ?? '';
|
||||
final color = details.region.color;
|
||||
|
||||
if (text == 'centerIcon') {
|
||||
if (text == kTimeRegionCenterIcon) {
|
||||
return Container(
|
||||
color: color,
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
widget.details.region.iconData,
|
||||
size: 17,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
child: Icon(details.region.iconData, size: 17, color: Theme.of(context).primaryColor),
|
||||
);
|
||||
} else if(text.startsWith('holiday')) {
|
||||
}
|
||||
|
||||
if (text.startsWith(kTimeRegionHolidayPrefix)) {
|
||||
return Container(
|
||||
color: color,
|
||||
alignment: Alignment.center,
|
||||
@@ -38,7 +35,7 @@ class _TimeRegionComponentState extends State<TimeRegionComponent> {
|
||||
RotatedBox(
|
||||
quarterTurns: 1,
|
||||
child: Text(
|
||||
text.split(':').last,
|
||||
text.substring(kTimeRegionHolidayPrefix.length),
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
Reference in New Issue
Block a user