diff --git a/school_data_hub_client/lib/src/protocol/_features/books/models/library_book_location.dart b/school_data_hub_client/lib/src/protocol/_features/books/models/library_book_location.dart index ab8d549a..65f1209e 100644 --- a/school_data_hub_client/lib/src/protocol/_features/books/models/library_book_location.dart +++ b/school_data_hub_client/lib/src/protocol/_features/books/models/library_book_location.dart @@ -10,6 +10,7 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'package:serverpod_client/serverpod_client.dart' as _i1; + import '../../../_features/books/models/library_book.dart' as _i2; abstract class LibraryBookLocation implements _i1.SerializableModel { @@ -66,6 +67,17 @@ abstract class LibraryBookLocation implements _i1.SerializableModel { String toString() { return _i1.SerializationManager.encode(this); } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is LibraryBookLocation && + other.id == id && + other.location == location; + } + + @override + int get hashCode => Object.hash(id, location); } class _Undefined {} diff --git a/school_data_hub_flutter/lib/app_utils/shorebird_code_push.dart b/school_data_hub_flutter/lib/app_utils/shorebird_code_push.dart index 61f327a7..5b6fdff8 100644 --- a/school_data_hub_flutter/lib/app_utils/shorebird_code_push.dart +++ b/school_data_hub_flutter/lib/app_utils/shorebird_code_push.dart @@ -213,7 +213,7 @@ class _CheckForUpdatesPageState extends State { ), ), - bottomNavigationBar: const BottomNavBarNoFilterButton(), + bottomNavigationBar: GenericBottomNavBarWithActions(), ); } } diff --git a/school_data_hub_flutter/lib/common/widgets/generic_components/bottom_nav_bar_no_filter_button.dart b/school_data_hub_flutter/lib/common/widgets/generic_components/bottom_nav_bar_no_filter_button.dart index ad7a526c..b1893ed0 100644 --- a/school_data_hub_flutter/lib/common/widgets/generic_components/bottom_nav_bar_no_filter_button.dart +++ b/school_data_hub_flutter/lib/common/widgets/generic_components/bottom_nav_bar_no_filter_button.dart @@ -3,8 +3,9 @@ import 'package:gap/gap.dart'; import 'package:school_data_hub_flutter/common/theme/app_colors.dart'; import 'package:school_data_hub_flutter/common/widgets/bottom_nav_bar_layouts.dart'; -class BottomNavBarNoFilterButton extends StatelessWidget { - const BottomNavBarNoFilterButton({super.key}); +class GenericBottomNavBarWithActions extends StatelessWidget { + final List? actions; + GenericBottomNavBarWithActions({this.actions, super.key}); @override Widget build(BuildContext context) { @@ -28,6 +29,8 @@ class BottomNavBarNoFilterButton extends StatelessWidget { Navigator.pop(context); }, ), + if (actions != null) + for (var action in actions!) ...[const Gap(15), action], const Gap(15), ], ), diff --git a/school_data_hub_flutter/lib/common/widgets/unencrypted_image_in_card.dart b/school_data_hub_flutter/lib/common/widgets/unencrypted_image_in_card.dart index ff3bcc23..d64ba446 100644 --- a/school_data_hub_flutter/lib/common/widgets/unencrypted_image_in_card.dart +++ b/school_data_hub_flutter/lib/common/widgets/unencrypted_image_in_card.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:school_data_hub_flutter/app_utils/create_and_crop_image_file.dart'; import 'package:school_data_hub_flutter/common/theme/app_colors.dart'; @@ -22,7 +23,16 @@ class UnencryptedImageInCard extends HookWidget { @override Widget build(BuildContext context) { + final refreshState = useState(0); final randomPart = useMemoized(() => UniqueKey().toString(), []); + final imageFuture = useMemoized( + () => cachedPublicImageOrDownloadPublicImage( + path: path, + cacheKey: cacheKey, + ), + [path, cacheKey, refreshState.value], + ); + return SizedBox( height: size, width: (21 / 30) * size, @@ -31,18 +41,18 @@ class UnencryptedImageInCard extends HookWidget { onLongPress: () async { final File? file = await createAndCropImageFile(context); if (file == null) return; + + await di().removeFile(cacheKey); await di().updateBookImage( file: file, isbn: int.tryParse(cacheKey)!, ); + refreshState.value++; }, child: WidgetZoom( heroAnimationTag: '$cacheKey$randomPart', zoomWidget: FutureBuilder( - future: cachedPublicImageOrDownloadPublicImage( - path: path, - cacheKey: cacheKey, - ), + future: imageFuture, builder: (context, snapshot) { Widget child; if (snapshot.connectionState == ConnectionState.waiting) { diff --git a/school_data_hub_flutter/lib/features/_attendance/presentation/attendance_page/attendance_list_page.dart b/school_data_hub_flutter/lib/features/_attendance/presentation/attendance_page/attendance_list_page.dart index 36f83a27..12ca590b 100644 --- a/school_data_hub_flutter/lib/features/_attendance/presentation/attendance_page/attendance_list_page.dart +++ b/school_data_hub_flutter/lib/features/_attendance/presentation/attendance_page/attendance_list_page.dart @@ -4,11 +4,11 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:logging/logging.dart'; import 'package:school_data_hub_client/school_data_hub_client.dart'; -import 'package:school_data_hub_flutter/core/models/datetime_extensions.dart'; import 'package:school_data_hub_flutter/common/services/notification_service.dart'; import 'package:school_data_hub_flutter/common/theme/app_colors.dart'; import 'package:school_data_hub_flutter/common/widgets/generic_components/generic_sliver_list.dart'; import 'package:school_data_hub_flutter/common/widgets/generic_components/generic_sliver_search_app_bar.dart'; +import 'package:school_data_hub_flutter/core/models/datetime_extensions.dart'; import 'package:school_data_hub_flutter/core/session/hub_session_manager.dart'; import 'package:school_data_hub_flutter/features/_attendance/domain/attendance_helper_functions.dart'; import 'package:school_data_hub_flutter/features/_attendance/domain/attendance_manager.dart'; diff --git a/school_data_hub_flutter/lib/features/_schoolday_events/domain/filters/schoolday_event_filter_helpers.dart b/school_data_hub_flutter/lib/features/_schoolday_events/domain/filters/schoolday_event_filter_helpers.dart deleted file mode 100644 index e749bf90..00000000 --- a/school_data_hub_flutter/lib/features/_schoolday_events/domain/filters/schoolday_event_filter_helpers.dart +++ /dev/null @@ -1,45 +0,0 @@ -// import 'package:schuldaten_hub/features/pupil/models/pupil_proxy.dart'; -// import 'package:schuldaten_hub/features/schoolday_events/models/schoolday_event.dart'; - -// class SchooldayEventFilterHelpers { -// static List schooldayEventsInTheLastSevenDays( -// PupilProxy pupil) { -// DateTime sevenDaysAgo = DateTime.now().subtract(const Duration(days: 7)); -// List schooldayEvents = []; -// if (pupil.schooldayEvents != null) { -// for (SchooldayEvent schooldayEvent in pupil.schooldayEvents!) { -// if (schooldayEvent.schooldayEventDate.isBefore(sevenDaysAgo)) { -// schooldayEvents.add(schooldayEvent); -// } -// } -// } -// return schooldayEvents; -// } - -// static List schooldayEventsNotProcessed(PupilProxy pupil) { -// List schooldayEvents = []; -// if (pupil.schooldayEvents != null) { -// for (SchooldayEvent schooldayEvent in pupil.schooldayEvents!) { -// if (schooldayEvent.processedBy == null) { -// schooldayEvents.add(schooldayEvent); -// } -// } -// } -// return schooldayEvents; -// } - -// static List schooldayEventsInTheLastFourteenDays( -// PupilProxy pupil) { -// DateTime fourteenDaysAgo = -// DateTime.now().subtract(const Duration(days: 14)); -// List schooldayEvents = []; -// if (pupil.schooldayEvents != null) { -// for (SchooldayEvent schooldayEvent in pupil.schooldayEvents!) { -// if (schooldayEvent.schooldayEventDate.isBefore(fourteenDaysAgo)) { -// schooldayEvents.add(schooldayEvent); -// } -// } -// } -// return schooldayEvents; -// } -// } diff --git a/school_data_hub_flutter/lib/features/_schoolday_events/domain/filters/schoolday_event_filter_manager.dart b/school_data_hub_flutter/lib/features/_schoolday_events/domain/filters/schoolday_event_filter_manager.dart index b1a46399..fe2580df 100644 --- a/school_data_hub_flutter/lib/features/_schoolday_events/domain/filters/schoolday_event_filter_manager.dart +++ b/school_data_hub_flutter/lib/features/_schoolday_events/domain/filters/schoolday_event_filter_manager.dart @@ -1,5 +1,6 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:school_data_hub_client/school_data_hub_client.dart'; import 'package:school_data_hub_flutter/common/domain/filters/filters_state_manager.dart'; import 'package:school_data_hub_flutter/features/_schoolday_events/domain/models/schoolday_event_enums.dart'; @@ -157,6 +158,39 @@ class SchooldayEventFilterManager { } } + if (activeFilters[SchooldayEventFilter.duringBreak]!) { + if (schooldayEvent.eventTime == null) { + continue; + } + final eventTimeOfDay = TimeOfDay( + hour: int.parse(schooldayEvent.eventTime!.split(':')[0]), + minute: int.parse(schooldayEvent.eventTime!.split(':')[1]), + ); + if (eventTimeOfDay.isAfter(const TimeOfDay(hour: 10, minute: 29)) && + eventTimeOfDay.isBefore(const TimeOfDay(hour: 11, minute: 20))) { + isMatched = true; + complementaryFilter = true; + } else if (!complementaryFilter) { + isMatched = false; + } + } + if (activeFilters[SchooldayEventFilter.notDuringBreak]!) { + if (schooldayEvent.eventTime == null) { + continue; + } + final eventTimeOfDay = TimeOfDay( + hour: int.parse(schooldayEvent.eventTime!.split(':')[0]), + minute: int.parse(schooldayEvent.eventTime!.split(':')[1]), + ); + if (eventTimeOfDay.isBefore(const TimeOfDay(hour: 10, minute: 29)) || + eventTimeOfDay.isAfter(const TimeOfDay(hour: 11, minute: 20))) { + isMatched = true; + complementaryFilter = true; + } else if (!complementaryFilter) { + isMatched = false; + } + } + //- The behavior of this first filter group should be //- excluding for the next filter group //- let's tidy up the list diff --git a/school_data_hub_flutter/lib/features/_schoolday_events/domain/models/schoolday_event_enums.dart b/school_data_hub_flutter/lib/features/_schoolday_events/domain/models/schoolday_event_enums.dart index 65605e6b..7527535b 100644 --- a/school_data_hub_flutter/lib/features/_schoolday_events/domain/models/schoolday_event_enums.dart +++ b/school_data_hub_flutter/lib/features/_schoolday_events/domain/models/schoolday_event_enums.dart @@ -38,6 +38,8 @@ enum SchooldayEventFilter { admonitionInfo, transitionAdvice, processed, + duringBreak, + notDuringBreak, } Map initialSchooldayEventFilterValues = { @@ -61,4 +63,6 @@ Map initialSchooldayEventFilterValues = { SchooldayEventFilter.admonitionInfo: false, SchooldayEventFilter.transitionAdvice: false, SchooldayEventFilter.processed: false, + SchooldayEventFilter.duringBreak: false, + SchooldayEventFilter.notDuringBreak: false, }; diff --git a/school_data_hub_flutter/lib/features/_schoolday_events/presentation/schoolday_event_list_page/widgets/schoolday_event_filter_bottom_sheet.dart b/school_data_hub_flutter/lib/features/_schoolday_events/presentation/schoolday_event_list_page/widgets/schoolday_event_filter_bottom_sheet.dart index 8c76bf9d..a0d8c3b0 100644 --- a/school_data_hub_flutter/lib/features/_schoolday_events/presentation/schoolday_event_list_page/widgets/schoolday_event_filter_bottom_sheet.dart +++ b/school_data_hub_flutter/lib/features/_schoolday_events/presentation/schoolday_event_list_page/widgets/schoolday_event_filter_bottom_sheet.dart @@ -63,7 +63,10 @@ class SchooldayEventFilterBottomSheet extends WatchingWidget { activeSchooldayEventFilters[SchooldayEventFilter.learningSupportInfo]!; bool valueAdmonitionInfo = activeSchooldayEventFilters[SchooldayEventFilter.admonitionInfo]!; - + bool valueDuringBreak = + activeSchooldayEventFilters[SchooldayEventFilter.duringBreak]!; + bool valueNotDuringBreak = + activeSchooldayEventFilters[SchooldayEventFilter.notDuringBreak]!; return Padding( padding: const EdgeInsets.only(left: 15.0, right: 15, top: 5), child: Column( @@ -184,6 +187,42 @@ class SchooldayEventFilterBottomSheet extends WatchingWidget { ); }, ), + ThemedFilterChip( + label: '🕒', + selected: valueDuringBreak, + onSelected: (val) { + _schooldayEventFilterManager.setFilter( + schooldayEventFilters: [ + ( + filter: SchooldayEventFilter.duringBreak, + value: val, + ), + ( + filter: SchooldayEventFilter.notDuringBreak, + value: !val, + ), + ], + ); + }, + ), + ThemedFilterChip( + label: '✏️', + selected: valueNotDuringBreak, + onSelected: (val) { + _schooldayEventFilterManager.setFilter( + schooldayEventFilters: [ + ( + filter: SchooldayEventFilter.notDuringBreak, + value: val, + ), + ( + filter: SchooldayEventFilter.duringBreak, + value: !val, + ), + ], + ); + }, + ), ], ), const Gap(10), diff --git a/school_data_hub_flutter/lib/features/app_settings/settings_page/widgets/settings_account_section.dart b/school_data_hub_flutter/lib/features/app_settings/settings_page/widgets/settings_account_section.dart index 6b6dd9e8..f7f0af50 100644 --- a/school_data_hub_flutter/lib/features/app_settings/settings_page/widgets/settings_account_section.dart +++ b/school_data_hub_flutter/lib/features/app_settings/settings_page/widgets/settings_account_section.dart @@ -1,8 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_settings_ui/flutter_settings_ui.dart'; -import 'package:gap/gap.dart'; -import 'package:school_data_hub_flutter/common/widgets/dialogs/confirmation_dialog.dart'; -import 'package:school_data_hub_flutter/core/session/hub_session_helper.dart'; import 'package:school_data_hub_flutter/core/session/hub_session_manager.dart'; import 'package:school_data_hub_flutter/features/user/presentation/change_password/change_password_page.dart'; import 'package:watch_it/watch_it.dart'; @@ -57,31 +54,6 @@ class SettingsAccountSection extends AbstractSettingsSection with WatchItMixin { trailing: null, ), - SettingsTile.navigation( - leading: GestureDetector( - onTap: () async { - bool? confirm = await confirmationDialog( - context: context, - title: 'Achtung!', - message: 'Ausloggen und alle Daten löschen?', - ); - if (confirm == true && context.mounted) { - SessionHelper.logoutAndDeleteAllInstanceData(); - } - return; - }, - child: const Row( - children: [ - Icon(Icons.logout), - Gap(5), - Icon(Icons.delete_forever_outlined), - ], - ), - ), - title: const Text('Ausloggen und Daten löschen'), - value: const Text('App wird zurückgesetzt!'), - //onPressed: - ), SettingsTile.navigation( leading: const Icon(Icons.attach_money_rounded), title: const Text('Guthaben'), diff --git a/school_data_hub_flutter/lib/features/app_settings/settings_page/widgets/settings_session_section.dart b/school_data_hub_flutter/lib/features/app_settings/settings_page/widgets/settings_session_section.dart index 14ddfc9c..8c93dea8 100644 --- a/school_data_hub_flutter/lib/features/app_settings/settings_page/widgets/settings_session_section.dart +++ b/school_data_hub_flutter/lib/features/app_settings/settings_page/widgets/settings_session_section.dart @@ -153,28 +153,6 @@ class SettingsSessionSection extends AbstractSettingsSection with WatchItMixin { trailing: null, ), - SettingsTile.navigation( - onPressed: (context) async { - final confirm = await confirmationDialog( - context: context, - title: 'Ausloggen', - message: 'Wirklich ausloggen?\n\nDaten bleiben erhalten!', - ); - if (confirm == true && context.mounted) { - di().signOutDevice(); - - _notificationService.showSnackBar( - NotificationType.success, - 'Erfolgreich ausgeloggt!', - ); - } - }, - leading: const Icon(Icons.logout), - title: const Text('Ausloggen'), - description: const Text('Daten bleiben erhalten'), - - //onPressed: - ), SettingsTile.navigation( leading: const Icon(Icons.perm_identity_rounded), title: const Text('Lokale Daten vom:'), @@ -287,13 +265,13 @@ class SettingsSessionSection extends AbstractSettingsSection with WatchItMixin { }, child: const Row( children: [ - Icon(Icons.logout), + Icon(Icons.image), Gap(5), Icon(Icons.delete_forever_outlined), ], ), ), - title: const Text('Lokal gespeicherte Bilder löschen'), + title: const Text('Bilder-Cache löschen'), //onPressed: ), @@ -322,6 +300,28 @@ class SettingsSessionSection extends AbstractSettingsSection with WatchItMixin { value: const Text('App wird zurückgesetzt!'), //onPressed: ), + SettingsTile.navigation( + onPressed: (context) async { + final confirm = await confirmationDialog( + context: context, + title: 'Ausloggen', + message: 'Wirklich ausloggen?\n\nDaten bleiben erhalten!', + ); + if (confirm == true && context.mounted) { + di().signOutDevice(); + + _notificationService.showSnackBar( + NotificationType.success, + 'Erfolgreich ausgeloggt!', + ); + } + }, + leading: const Icon(Icons.logout), + title: const Text('Ausloggen'), + description: const Text('Daten bleiben erhalten'), + + //onPressed: + ), ], ); } diff --git a/school_data_hub_flutter/lib/features/books/presentation/book_infos_page/book_infos_page.dart b/school_data_hub_flutter/lib/features/books/presentation/book_infos_page/book_infos_page.dart new file mode 100644 index 00000000..4985161b --- /dev/null +++ b/school_data_hub_flutter/lib/features/books/presentation/book_infos_page/book_infos_page.dart @@ -0,0 +1,331 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:school_data_hub_flutter/common/theme/app_colors.dart'; +import 'package:school_data_hub_flutter/common/widgets/bottom_nav_bar_layouts.dart'; +import 'package:school_data_hub_flutter/common/widgets/dialogs/long_textfield_dialog.dart'; +import 'package:school_data_hub_flutter/common/widgets/generic_components/bottom_nav_bar_no_filter_button.dart'; +import 'package:school_data_hub_flutter/common/widgets/generic_components/generic_app_bar.dart'; +import 'package:school_data_hub_flutter/features/books/data/book_api_service.dart'; +import 'package:school_data_hub_flutter/features/books/domain/book_helper.dart'; +import 'package:school_data_hub_flutter/features/books/domain/book_manager.dart'; +import 'package:school_data_hub_flutter/features/books/domain/models/library_book_proxy.dart'; +import 'package:school_data_hub_flutter/features/books/presentation/book_infos_page/widgets/book_header.dart'; +import 'package:school_data_hub_flutter/features/books/presentation/book_list_page/widgets/book_pupil_card.dart'; +import 'package:school_data_hub_flutter/features/books/presentation/new_book_page/new_book_controller.dart'; +import 'package:watch_it/watch_it.dart'; + +class BookInfosPage extends WatchingStatefulWidget { + final String libraryId; + + const BookInfosPage({super.key, required this.libraryId}); + + @override + State createState() => _BookInfosPageState(); +} + +class _BookInfosPageState extends State { + LibraryBookProxy? _bookProxy; + bool _isLoading = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadBook(); + } + + Future _loadBook() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final libraryBook = await BookApiService().fetchLibraryBookByLibraryId( + widget.libraryId, + ); + + if (libraryBook != null) { + setState(() { + _bookProxy = LibraryBookProxy(librarybook: libraryBook); + _isLoading = false; + }); + } else { + setState(() { + _errorMessage = 'Buch nicht gefunden'; + _isLoading = false; + }); + } + } catch (e) { + setState(() { + _errorMessage = 'Fehler beim Laden: $e'; + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.canvasColor, + appBar: const GenericAppBar(title: 'Buch Details', iconData: Icons.book), + body: _buildBody(), + bottomNavigationBar: BottomNavBarLayout( + bottomNavBar: GenericBottomNavBarWithActions( + actions: [ + IconButton( + tooltip: 'Buch bearbeiten', + icon: const Icon(Icons.edit, size: 30), + onPressed: _editBook, + ), + ], + ), + ), + ); + } + + void _editBook() { + if (_bookProxy == null) return; + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => NewBook( + isEdit: true, + isbn: _bookProxy!.isbn, + libraryId: _bookProxy!.libraryId, + bookTitle: _bookProxy!.title, + bookAuthor: _bookProxy!.author, + bookDescription: _bookProxy!.description, + bookReadingLevel: _bookProxy!.readingLevel, + location: _bookProxy!.location, + bookAvailable: _bookProxy!.available, + imageId: _bookProxy!.imagePath, + ), + ), + ).then((_) { + // Reload book after editing + _loadBook(); + }); + } + + Widget _buildBody() { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_errorMessage != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(_errorMessage!, style: const TextStyle(color: Colors.red)), + const Gap(10), + ElevatedButton( + onPressed: _loadBook, + child: const Text('Erneut versuchen'), + ), + ], + ), + ); + } + + if (_bookProxy == null) { + return const Center(child: Text('Kein Buch gefunden')); + } + + final bookProxy = _bookProxy!; + + return Center( + heightFactor: 1, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 800), + child: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + BookHeader(bookProxy: bookProxy), + const Gap(20), + _buildDescription(bookProxy), + const Gap(20), + _buildOtherCopies(bookProxy), + const Gap(20), + _buildLendings(bookProxy), + const Gap(20), + ], + ), + ), + ), + ); + } + + Widget _buildDescription(LibraryBookProxy bookProxy) { + return Card( + color: Colors.white, + surfaceTintColor: Colors.white, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Beschreibung:', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const Gap(8), + InkWell( + onTap: () async { + final String? description = await longTextFieldDialog( + title: 'Beschreibung', + labelText: 'Beschreibung', + initialValue: bookProxy.description, + parentContext: context, + ); + if (description != null && + description != bookProxy.description) { + await di().updateLibraryBookAndBookProperties( + isbn: bookProxy.isbn, + libraryId: bookProxy.libraryId, + description: description, + ); + _loadBook(); // Reload to reflect changes + } + }, + child: Text( + bookProxy.description.isNotEmpty + ? bookProxy.description + : 'Keine Beschreibung verfügbar.', + style: TextStyle( + fontSize: 14, + color: bookProxy.description.isEmpty + ? Colors.grey + : Colors.black, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildOtherCopies(LibraryBookProxy bookProxy) { + final otherCopies = di() + .getLibraryBooksByIsbn(bookProxy.isbn) + .where((book) => book.libraryId != bookProxy.libraryId) + .toList(); + + if (otherCopies.isEmpty) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Weitere Exemplare:', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const Gap(10), + Card( + color: Colors.white, + surfaceTintColor: Colors.white, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: otherCopies.map((book) { + return InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + BookInfosPage(libraryId: book.libraryId), + ), + ).then((_) => _loadBook()); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + Icon( + book.available + ? Icons.check_circle + : Icons.hourglass_empty, + color: book.available ? Colors.green : Colors.orange, + size: 20, + ), + const Gap(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Exemplar: ${book.libraryId}', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + Text( + 'Ort: ${book.location.location}', + style: const TextStyle(fontSize: 12), + ), + ], + ), + ), + const Icon( + Icons.arrow_forward_ios, + size: 16, + color: Colors.grey, + ), + ], + ), + ), + ); + }).toList(), + ), + ), + ), + ], + ); + } + + Widget _buildLendings(LibraryBookProxy bookProxy) { + final lendings = BookHelpers.pupilBookLendingsLinkedToLibraryBook( + libraryBookId: bookProxy.id, + ); + + // Sort lendings: current (not returned) first, then by date desc + final sortedLendings = lendings.toList(); + sortedLendings.sort((a, b) { + if (a.returnedAt == null && b.returnedAt != null) return -1; + if (a.returnedAt != null && b.returnedAt == null) return 1; + return b.lentAt.compareTo(a.lentAt); + }); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Ausleihen:', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const Gap(10), + if (sortedLendings.isEmpty) + const Card( + color: Colors.white, + surfaceTintColor: Colors.white, + child: Padding( + padding: EdgeInsets.all(16.0), + child: Center(child: Text('Keine Ausleihen vorhanden')), + ), + ) + else + ...sortedLendings.map( + (lending) => BookLendingPupilCard(passedPupilBook: lending), + ), + ], + ); + } +} diff --git a/school_data_hub_flutter/lib/features/books/presentation/book_infos_page/widgets/book_header.dart b/school_data_hub_flutter/lib/features/books/presentation/book_infos_page/widgets/book_header.dart new file mode 100644 index 00000000..5ad4fc5e --- /dev/null +++ b/school_data_hub_flutter/lib/features/books/presentation/book_infos_page/widgets/book_header.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:school_data_hub_flutter/app_utils/extensions/isbn_extensions.dart'; +import 'package:school_data_hub_flutter/common/theme/app_colors.dart'; +import 'package:school_data_hub_flutter/common/widgets/unencrypted_image_in_card.dart'; +import 'package:school_data_hub_flutter/features/books/domain/models/enums.dart'; +import 'package:school_data_hub_flutter/features/books/domain/models/library_book_proxy.dart'; +import 'package:watch_it/watch_it.dart'; + +class BookHeader extends WatchingWidget { + final LibraryBookProxy bookProxy; + const BookHeader({required this.bookProxy, super.key}); + + Widget _buildInfoRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), + const Gap(8), + Expanded(child: Text(value)), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Card( + color: Colors.white, + surfaceTintColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + bookProxy.title, + style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + Text( + bookProxy.author, + style: const TextStyle(fontSize: 16, color: Colors.grey), + ), + const Gap(15), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UnencryptedImageInCard( + cacheKey: bookProxy.isbn.toString(), + path: bookProxy.imagePath, + size: 120, + ), + const Gap(15), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoRow('ISBN:', bookProxy.isbn.displayAsIsbn()), + _buildInfoRow( + 'LeseStufe:', + bookProxy.readingLevel ?? ReadingLevel.notSet.value, + ), + _buildInfoRow('Ort:', bookProxy.location.location), + const Gap(5), + Wrap( + spacing: 4, + runSpacing: 4, + children: [ + const Text('Tags: '), + if (bookProxy.bookTags.isEmpty) + const Text( + 'Keine Tags', + style: TextStyle( + fontSize: 12, + color: Colors.grey, + fontStyle: FontStyle.italic, + ), + ) + else + for (final tag in bookProxy.bookTags) + Chip( + label: Text( + tag.name, + style: const TextStyle( + fontSize: 10, + color: Colors.white, + ), + ), + backgroundColor: AppColors.interactiveColor, + padding: EdgeInsets.zero, + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + ), + ], + ), + ], + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/school_data_hub_flutter/lib/features/books/presentation/book_search_page/book_search_result_card.dart b/school_data_hub_flutter/lib/features/books/presentation/book_search_page/book_search_result_card.dart index 61889973..b3a8d3ff 100644 --- a/school_data_hub_flutter/lib/features/books/presentation/book_search_page/book_search_result_card.dart +++ b/school_data_hub_flutter/lib/features/books/presentation/book_search_page/book_search_result_card.dart @@ -25,7 +25,7 @@ class SearchResultBookCard extends WatchingWidget { final descriptionTileController = createOnce( () => ExpansibleController(), ); - + watch(bookProxy); return ClipRRect( borderRadius: BorderRadius.circular(20), child: Card( diff --git a/school_data_hub_flutter/lib/features/books/presentation/books_main_menu_page/books_main_menu_page.dart b/school_data_hub_flutter/lib/features/books/presentation/books_main_menu_page/books_main_menu_page.dart index 8b34356b..24c7b94c 100644 --- a/school_data_hub_flutter/lib/features/books/presentation/books_main_menu_page/books_main_menu_page.dart +++ b/school_data_hub_flutter/lib/features/books/presentation/books_main_menu_page/books_main_menu_page.dart @@ -8,6 +8,7 @@ import 'package:school_data_hub_flutter/app_utils/scanner.dart'; import 'package:school_data_hub_flutter/common/services/notification_service.dart'; import 'package:school_data_hub_flutter/common/widgets/generic_components/generic_app_bar.dart'; import 'package:school_data_hub_flutter/features/books/domain/book_manager.dart'; +import 'package:school_data_hub_flutter/features/books/presentation/book_infos_page/book_infos_page.dart'; import 'package:school_data_hub_flutter/features/books/presentation/book_search_form/book_search_form_page.dart'; import 'package:school_data_hub_flutter/features/books/presentation/book_tag_management_page/book_tag_management_controller.dart'; import 'package:watch_it/watch_it.dart'; @@ -53,9 +54,22 @@ class BooksMainMenuPage extends WatchingWidget { // ); // }), const SizedBox(height: 5), - _buildButton(context, "Buch erfassen", () async { - await _showNewBookDialog(context); - }, icon: Icons.qr_code_scanner), + _buildButton( + context, + "Buch erfassen", + () async { + await _showNewBookDialog(context); + }, + icon: Icons.qr_code_scanner, + onLongPress: () async { + // For Windows: allow manual ISBN entry + await _showNewBookDialog(context, dialog: true); + }, + ), + const SizedBox(height: 5), + _buildButton(context, "Buch-Infos", () async { + await _showBookInfosDialog(context); + }, icon: Icons.info_outline), const SizedBox(height: 5), _buildButton(context, "Bücher suchen", () { Navigator.of(context).push( @@ -142,12 +156,14 @@ class BooksMainMenuPage extends WatchingWidget { BuildContext context, String label, VoidCallback onTap, { + VoidCallback? onLongPress, IconData? icon, }) { return Padding( padding: const EdgeInsets.all(4.0), child: InkWell( onTap: onTap, + onLongPress: onLongPress, child: Card( color: AppColors.backgroundColor, shape: RoundedRectangleBorder( @@ -181,8 +197,8 @@ class BooksMainMenuPage extends WatchingWidget { } } -Future _showNewBookDialog(BuildContext context) async { - if (Platform.isWindows) { +Future _showNewBookDialog(BuildContext context, {bool? dialog}) async { + if (Platform.isWindows || dialog == true) { final isbn = await shortTextfieldDialog( context: context, title: 'ISBN eingeben', @@ -222,3 +238,34 @@ Future _showNewBookDialog(BuildContext context) async { ); } } + +Future _showBookInfosDialog(BuildContext context) async { + if (Platform.isWindows) { + final libraryId = await shortTextfieldDialog( + context: context, + title: 'Buch-ID eingeben', + labelText: 'Buch-ID', + hintText: 'Bitte geben Sie die Buch-ID ein', + ); + + if (libraryId != null && libraryId.isNotEmpty) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (ctx) => BookInfosPage(libraryId: libraryId), + ), + ); + } + } else { + final String? scannedLibraryId = await qrScanner( + context: context, + overlayText: 'Buch-ID scannen', + ); + if (scannedLibraryId == null) return; + + final bookId = scannedLibraryId.replaceFirst('Buch ID: ', '').trim(); + + Navigator.of(context).push( + MaterialPageRoute(builder: (ctx) => BookInfosPage(libraryId: bookId)), + ); + } +} diff --git a/school_data_hub_flutter/lib/features/books/presentation/new_book_page/new_book_controller.dart b/school_data_hub_flutter/lib/features/books/presentation/new_book_page/new_book_controller.dart index 65a7b0fe..f67745f9 100644 --- a/school_data_hub_flutter/lib/features/books/presentation/new_book_page/new_book_controller.dart +++ b/school_data_hub_flutter/lib/features/books/presentation/new_book_page/new_book_controller.dart @@ -166,13 +166,20 @@ class NewBookController extends State { final allLocations = di().locations.value.toList(); final lastLocation = di().lastLocationValue.value; - // Check if lastLocation is already in the list to avoid duplicates - final isLastLocationInList = allLocations.any( - (location) => location.id == lastLocation.id, + // Remove duplicates from the list first (based on ID/content equality) + // We can use a Set, but we need equality operator on LibraryBookLocation first. + // Assuming we added equality operator in the model. + final uniqueLocations = allLocations.toSet().toList(); + + locationDropdownItems.clear(); + + // Check if lastLocation is already in the list + final isLastLocationInList = uniqueLocations.any( + (location) => location == lastLocation, ); - // Add all locations from the list - for (final location in allLocations) { + // Add all locations from the unique list + for (final location in uniqueLocations) { locationDropdownItems.add( DropdownMenuItem(value: location, child: Text(location.location)), ); @@ -207,6 +214,15 @@ class NewBookController extends State { locations.addAll(di().locations.value); locationDropdownItems.clear(); _createDropdownItems(); + + // Check if current selection still exists, if not, reset or select default + // We check if the lastLocationValue exists in the updated items by comparing IDs/Objects + // Since objects might be new instances, we might need robust check. + // But _createDropdownItems adds the *current* lastLocationValue if missing. + // So effectively, lastLocationValue is always "valid" in the sense that it's in the list. + // BUT: The error says "Either zero or 2 or more ... detected". + // This implies duplication in the items list OR that the value passed to DropdownButton + // matches multiple items in the list. }); } diff --git a/school_data_hub_flutter/lib/features/books/presentation/widgets/pupil_book_card.dart b/school_data_hub_flutter/lib/features/books/presentation/widgets/pupil_book_card.dart index a51043fd..764c0f95 100644 --- a/school_data_hub_flutter/lib/features/books/presentation/widgets/pupil_book_card.dart +++ b/school_data_hub_flutter/lib/features/books/presentation/widgets/pupil_book_card.dart @@ -42,8 +42,11 @@ class PupilBookLendingCard extends StatelessWidget { color: AppColors.cardInCardColor, child: InkWell( onLongPress: () async { - if (pupilBookLending.lentBy != di().userName || - !di().isAdmin) { + final isAuthorized = + di().isAdmin || + di().userName == pupilBookLending.lentBy; + + if (!isAuthorized) { informationDialog( context, 'Keine Berechtigung', diff --git a/school_data_hub_flutter/lib/features/statistics/chart_page/chart_page.dart b/school_data_hub_flutter/lib/features/statistics/chart_page/chart_page.dart index 8694dfc3..f10ce0bc 100644 --- a/school_data_hub_flutter/lib/features/statistics/chart_page/chart_page.dart +++ b/school_data_hub_flutter/lib/features/statistics/chart_page/chart_page.dart @@ -65,7 +65,7 @@ class ChartPage extends WatchingWidget { title: 'Statistik Diagramm', ), body: const Center(child: Text('Keine Daten verfügbar')), - bottomNavigationBar: const BottomNavBarNoFilterButton(), + bottomNavigationBar: GenericBottomNavBarWithActions(), ); } diff --git a/school_data_hub_server/lib/src/_features/books/endpoints/library_books/library_books_endpoint.dart b/school_data_hub_server/lib/src/_features/books/endpoints/library_books/library_books_endpoint.dart index 025dc9b5..b9de516f 100644 --- a/school_data_hub_server/lib/src/_features/books/endpoints/library_books/library_books_endpoint.dart +++ b/school_data_hub_server/lib/src/_features/books/endpoints/library_books/library_books_endpoint.dart @@ -241,8 +241,6 @@ class LibraryBooksEndpoint extends Endpoint { } await Book.db.updateRow(session, book, transaction: transaction); } - - return libraryBook; }); final updatedLibraryBook = await LibraryBook.db.findFirstRow( session, diff --git a/school_data_hub_server/lib/src/utils/isbn_api.dart b/school_data_hub_server/lib/src/utils/isbn_api.dart index 3124ca42..964c34bf 100644 --- a/school_data_hub_server/lib/src/utils/isbn_api.dart +++ b/school_data_hub_server/lib/src/utils/isbn_api.dart @@ -28,7 +28,14 @@ class IsbnApi { final response = await http.get(Uri.parse(url)); if (response.statusCode != 200) { - throw Exception('Failed to load book data'); + // The api had no data for this isbn, we need t return a placeholder book + // to let the user type the book data himself + return IsbnApiData( + author: 'Unbekannt', + title: 'Unbekannt', + description: 'Unbekannt', + imagePath: + 'https://hermannschule.de/Bilder/kein_bild_vorhanden.jpeg'); } final document = parse(response.body); @@ -50,17 +57,18 @@ class IsbnApi { .replaceAll('
', '') .replaceAll(RegExp(r'<[^>]*>'), '') .trim(); - - description = description.replaceAll('\n', ''); - // Replace multiple consecutive spaces with a newline - description = description.replaceAll(RegExp(r' {2,}'), '\n'); + description = description + .replaceAll('\n', '') + .replaceAll(RegExp(r' {2,}'), '\n') + .replaceFirst('Verfügbarkeit jetzt prüfen', '') + .trim(); } final image = await http.get(Uri.parse(imageUrl)); if (image.statusCode != 200) { // If the image is not found, we return a placeholder image return IsbnApiData( - imagePath: 'https://via.placeholder.com/150', + imagePath: 'https://hermannschule.de/Bilder/kein_bild_vorhanden.jpeg', title: title, author: author, description: description);