diff --git a/CHANGELOG.md b/CHANGELOG.md index d6b0a19..64c1e00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.9.0 + +* Beta release + ## 0.0.1 * initial release \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 87960ff..37b9edf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,6 @@ We welcome community contributions! If you're thinking of contributing, thank yo We ask that all contributors abide by our [code of conduct](https://github.com/fleeser/habib_app/blob/production/code_of_conduct.md) - ### Opening Issues We have templates for questions, features, or bug reports, please follow the directions in these templates, but generally: diff --git a/README.md b/README.md index cb57856..677a5c0 100644 --- a/README.md +++ b/README.md @@ -1 +1,66 @@ -# Habib App \ No newline at end of file +# Habib App + +## Contributors ❤️ + +We wanna thank everybody that has been contributing to this project! + + + + + + + + + + +
+
+ Profilbild 1 +
Name 1
+
+
+ Profilbild 2 +
Name 2
+
+
+ Profilbild 3 +
Name 3
+
+ +
+ + + + + + +
+ Pascal Stellmacher +
Pascal Stellmacher
+
\ No newline at end of file diff --git a/flutter/lib/core/common/models/error_details.dart b/flutter/lib/core/common/models/error_details.dart new file mode 100644 index 0000000..694448e --- /dev/null +++ b/flutter/lib/core/common/models/error_details.dart @@ -0,0 +1,10 @@ +class ErrorDetails { + + final Object error; + final StackTrace stackTrace; + + ErrorDetails({ + required this.error, + StackTrace? stackTrace + }) : stackTrace = stackTrace ?? StackTrace.current; +} \ No newline at end of file diff --git a/flutter/lib/core/common/widgets/hb_app_bar.dart b/flutter/lib/core/common/widgets/hb_app_bar.dart index c8a47a4..b11b40c 100644 --- a/flutter/lib/core/common/widgets/hb_app_bar.dart +++ b/flutter/lib/core/common/widgets/hb_app_bar.dart @@ -97,10 +97,10 @@ class HBAppBarButton extends StatelessWidget { onPressed: onPressed, enableFeedback: !isLoading && isEnabled, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(HBUIConstants.appBarButtonSize / 2.0)), - fillColor: HBColors.gray300, + fillColor: HBColors.gray900, child: HBIcon( icon: icon!, - color: HBColors.gray900, + color: HBColors.gray100, size: HBUIConstants.appBarButtonSize / 2.0 ) ) diff --git a/flutter/lib/core/common/widgets/hb_button.dart b/flutter/lib/core/common/widgets/hb_button.dart index 09cca0a..ff400c3 100644 --- a/flutter/lib/core/common/widgets/hb_button.dart +++ b/flutter/lib/core/common/widgets/hb_button.dart @@ -15,12 +15,14 @@ class HBButton extends StatelessWidget { final HBIcons? icon; final bool isShrinked; final bool isOutlined; + final double? maxWidth; const HBButton.fill({ super.key, this.onPressed, this.title, this.icon, + this.maxWidth }) : isShrinked = false, isOutlined = false; @@ -28,7 +30,8 @@ class HBButton extends StatelessWidget { super.key, this.onPressed, this.title, - this.icon + this.icon, + this.maxWidth }) : isShrinked = false, isOutlined = true; @@ -38,7 +41,8 @@ class HBButton extends StatelessWidget { this.title, this.icon, }) : isShrinked = true, - isOutlined = false; + isOutlined = false, + maxWidth = null; const HBButton.shrinkOutline({ super.key, @@ -46,7 +50,8 @@ class HBButton extends StatelessWidget { this.title, this.icon }) : isShrinked = true, - isOutlined = true; + isOutlined = true, + maxWidth = null; @override Widget build(BuildContext context) { @@ -55,59 +60,62 @@ class HBButton extends StatelessWidget { ? HBUIConstants.buttonShrinkedHeight : HBUIConstants.buttonHeight; - return SizedBox( - height: buttonHeight, - width: isShrinked - ? null - : double.infinity, - child: RawMaterialButton( - onPressed: onPressed, - fillColor: isOutlined + return ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), + child: SizedBox( + height: buttonHeight, + width: isShrinked ? null - : HBColors.gray900, - padding: const EdgeInsets.symmetric(horizontal: HBSpacing.lg), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(HBUIConstants.defaultBorderRadius), - side: BorderSide( - color: isOutlined - ? HBColors.gray900 - : Colors.transparent, - width: isOutlined - ? 1.5 - : 0.0 - ) - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - if (icon != null) HBIcon( - icon: icon!, - size: buttonHeight * 0.5, + : double.infinity, + child: RawMaterialButton( + onPressed: onPressed, + fillColor: isOutlined + ? null + : HBColors.gray900, + padding: const EdgeInsets.symmetric(horizontal: HBSpacing.lg), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(HBUIConstants.defaultBorderRadius), + side: BorderSide( color: isOutlined ? HBColors.gray900 - : HBColors.gray100 - ), - if (icon != null && title != null) const HBGap.md(), - if (title != null) Flexible( - child: Text( - title!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: HBTypography.base.copyWith( - fontSize: isShrinked - ? 14.0 - : 16.0, - fontWeight: isShrinked - ? FontWeight.w400 - : FontWeight.w600, - color: isOutlined - ? HBColors.gray900 - : HBColors.gray100 + : Colors.transparent, + width: isOutlined + ? 1.5 + : 0.0 + ) + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) HBIcon( + icon: icon!, + size: buttonHeight * 0.5, + color: isOutlined + ? HBColors.gray900 + : HBColors.gray100 + ), + if (icon != null && title != null) const HBGap.md(), + if (title != null) Flexible( + child: Text( + title!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: isShrinked + ? 14.0 + : 16.0, + fontWeight: isShrinked + ? FontWeight.w400 + : FontWeight.w600, + color: isOutlined + ? HBColors.gray900 + : HBColors.gray100 + ) ) ) - ) - ] + ] + ) ) ) ); diff --git a/flutter/lib/core/common/widgets/hb_checkbox.dart b/flutter/lib/core/common/widgets/hb_checkbox.dart new file mode 100644 index 0000000..9d80cb9 --- /dev/null +++ b/flutter/lib/core/common/widgets/hb_checkbox.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +import 'package:habib_app/core/common/widgets/hb_gap.dart'; +import 'package:habib_app/core/res/theme/colors/hb_colors.dart'; +import 'package:habib_app/core/res/theme/typography/hb_typography.dart'; + +class HBCheckbox extends StatelessWidget { + + final void Function(bool?)? onChanged; + final bool isSelected; + final String text; + + const HBCheckbox({ + super.key, + this.onChanged, + this.isSelected = false, + required this.text + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Checkbox( + value: isSelected, + onChanged: onChanged, + checkColor: HBColors.gray900, + fillColor: const WidgetStatePropertyAll(HBColors.gray300), + side: BorderSide.none, + ), + const HBGap.lg(), + Flexible( + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + color: HBColors.gray900, + fontWeight: FontWeight.w400, + fontSize: 14.0 + ) + ) + ) + ] + ); + } +} \ No newline at end of file diff --git a/flutter/lib/core/common/widgets/hb_chip.dart b/flutter/lib/core/common/widgets/hb_chip.dart index a21294e..8332395 100644 --- a/flutter/lib/core/common/widgets/hb_chip.dart +++ b/flutter/lib/core/common/widgets/hb_chip.dart @@ -3,20 +3,75 @@ import 'package:flutter/material.dart'; import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; import 'package:habib_app/core/res/theme/typography/hb_typography.dart'; +class HBChips extends StatelessWidget { + + final List chips; + + const HBChips({ + super.key, + required this.chips + }); + + @override + Widget build(BuildContext context) { + return Wrap( + direction: Axis.horizontal, + alignment: WrapAlignment.start, + spacing: HBSpacing.md, + runSpacing: HBSpacing.md, + children: chips + ); + } +} + class HBChip extends StatelessWidget { final String text; final Color color; + final void Function()? onPressed; const HBChip({ super.key, required this.text, - required this.color + required this.color, + this.onPressed }); @override Widget build(BuildContext context) { - return Container( + + return SizedBox( + height: 30.0, + child: RawMaterialButton( + onPressed: onPressed, + elevation: 0.0, + focusElevation: 0.0, + hoverElevation: 0.0, + disabledElevation: 0.0, + highlightElevation: 0.0, + padding: const EdgeInsets.symmetric(horizontal: HBSpacing.md), + fillColor: color.withOpacity(0.4), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15.0), + side: BorderSide( + width: 1.5, + color: color + ) + ), + child: Text( + text, + textAlign: TextAlign.center, + style: HBTypography.base.copyWith( + fontSize: 14.0, + fontWeight: FontWeight.w600, + color: color + ) + ) + ) + ); + + + /*Container( height: 30.0, padding: const EdgeInsets.symmetric(horizontal: HBSpacing.md), alignment: Alignment.center, @@ -37,6 +92,6 @@ class HBChip extends StatelessWidget { color: color ) ) - ); + )*/ } } \ No newline at end of file diff --git a/flutter/lib/core/common/widgets/hb_date_button.dart b/flutter/lib/core/common/widgets/hb_date_button.dart new file mode 100644 index 0000000..81c40c3 --- /dev/null +++ b/flutter/lib/core/common/widgets/hb_date_button.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; + +import 'package:board_datetime_picker/board_datetime_picker.dart'; +import 'package:flutter_conditional/flutter_conditional.dart'; + +import 'package:habib_app/core/common/widgets/hb_gap.dart'; +import 'package:habib_app/core/extensions/datetime_extension.dart'; +import 'package:habib_app/core/res/theme/colors/hb_colors.dart'; +import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; +import 'package:habib_app/core/res/theme/typography/hb_typography.dart'; +import 'package:habib_app/core/utils/constants/hb_ui_constants.dart'; + +Future showHBDatePicker( + BuildContext context, { + String? title, + DateTime? initialDateTime +}) async { + + return await showBoardDateTimePicker( + context: context, + pickerType: DateTimePickerType.date, + initialDate: initialDateTime, + options: BoardDateTimeOptions( + languages: const BoardPickerLanguages( + today: 'Heute', + tomorrow: 'Morgen', + locale: 'de' + ), + backgroundColor: HBColors.gray900, + foregroundColor: HBColors.gray800, + textColor: HBColors.gray100, + activeColor: HBColors.gray100, + activeTextColor: HBColors.gray900, + startDayOfWeek: DateTime.monday, + pickerFormat: PickerFormat.dmy, + boardTitle: title, + pickerSubTitles: const BoardDateTimeItemTitles( + year: 'Jahr', + month: 'Monat', + day: 'Tag' + ) + ), + radius: HBUIConstants.defaultBorderRadius + ); +} + +class HBDateButton extends StatelessWidget { + + final String title; + final DateTime? dateTime; + final bool isEnabled; + final void Function(DateTime)? onChanged; + final String? emptyText; + + const HBDateButton({ + super.key, + required this.title, + this.dateTime, + this.isEnabled = true, + this.onChanged, + this.emptyText + }); + + Future _handlePressed(context) async { + DateTime? result = await showHBDatePicker(context); + + if (result != null) { + onChanged?.call(result); + } + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 16.0, + fontWeight: FontWeight.w400, + color: HBColors.gray900 + ) + ), + const HBGap.md(), + SizedBox( + height: 52.0, + width: double.infinity, + child: RawMaterialButton( + onPressed: () => _handlePressed(context), + elevation: 0.0, + focusElevation: 0.0, + highlightElevation: 0.0, + hoverElevation: 0.0, + enableFeedback: isEnabled, + fillColor: HBColors.gray200, + padding: const EdgeInsets.symmetric(horizontal: HBSpacing.md), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(HBUIConstants.defaultBorderRadius)), + child: Align( + alignment: Alignment.centerLeft, + child: Conditional.single( + condition: dateTime == null, + widget: Text( + emptyText ?? '', + style: HBTypography.base.copyWith( + color: HBColors.gray500, + fontWeight: FontWeight.w400, + fontSize: 14.0 + ) + ), + fallback: Text( + dateTime?.toHumanReadableDate() ?? '', + style: HBTypography.base.copyWith( + color: HBColors.gray900, + fontWeight: FontWeight.w400, + fontSize: 16.0 + ) + ) + ) + ) + ) + ) + ] + ); + } +} \ No newline at end of file diff --git a/flutter/lib/core/common/widgets/hb_dialog.dart b/flutter/lib/core/common/widgets/hb_dialog.dart index 6cca7bb..41d81ff 100644 --- a/flutter/lib/core/common/widgets/hb_dialog.dart +++ b/flutter/lib/core/common/widgets/hb_dialog.dart @@ -125,25 +125,29 @@ class HBDialogActionButton { class HBDialogSection extends StatelessWidget { final String title; + final bool isFirstSection; const HBDialogSection({ super.key, - required this.title + required this.title, + this.isFirstSection = false }); @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.only( - top: HBSpacing.xl, - bottom: HBSpacing.lg + padding: EdgeInsets.only( + top: isFirstSection + ? HBSpacing.lg + : HBSpacing.xxl, + bottom: HBSpacing.xl ), child: Text( title, maxLines: 1, overflow: TextOverflow.ellipsis, style: HBTypography.base.copyWith( - fontSize: 16.0, + fontSize: 20.0, fontWeight: FontWeight.w600, color: HBColors.gray900 ) diff --git a/flutter/lib/core/common/widgets/hb_light_button.dart b/flutter/lib/core/common/widgets/hb_light_button.dart new file mode 100644 index 0000000..31674de --- /dev/null +++ b/flutter/lib/core/common/widgets/hb_light_button.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +import 'package:habib_app/core/res/theme/colors/hb_colors.dart'; +import 'package:habib_app/core/res/theme/typography/hb_typography.dart'; + +class HBLightButton extends StatelessWidget { + + final void Function()? onPressed; + final bool isEnabled; + final TextAlign? textAlign; + final int maxLines; + final TextOverflow overflow; + final String title; + + const HBLightButton({ + super.key, + this.onPressed, + this.isEnabled = true, + this.textAlign, + this.maxLines = 1, + this.overflow = TextOverflow.ellipsis, + required this.title + }); + + void _onPressed() { + if (isEnabled) { + onPressed?.call(); + } + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: _onPressed, + child: Text( + title, + maxLines: maxLines, + overflow: overflow, + style: HBTypography.base.copyWith( + fontSize: 14.0, + fontWeight: FontWeight.w400, + color: HBColors.gray900, + decoration: TextDecoration.underline + ) + ) + ) + ); + } +} \ No newline at end of file diff --git a/flutter/lib/core/common/widgets/hb_message_box.dart b/flutter/lib/core/common/widgets/hb_message_box.dart new file mode 100644 index 0000000..a9b47ec --- /dev/null +++ b/flutter/lib/core/common/widgets/hb_message_box.dart @@ -0,0 +1,144 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:flutter_conditional/flutter_conditional.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:habib_app/core/common/widgets/hb_button.dart'; +import 'package:habib_app/core/common/widgets/hb_gap.dart'; +import 'package:habib_app/core/res/theme/colors/hb_colors.dart'; +import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; +import 'package:habib_app/core/res/theme/typography/hb_typography.dart'; +import 'package:habib_app/core/utils/constants/hb_ui_constants.dart'; + +Future showHBMessageBox( + BuildContext context, + String title, + String description, + String primaryButtonTitle, { + FutureOr Function()? onPressed + } +) async { + return await showDialog( + context: context, + builder: (BuildContext context) => HBMessageBox( + title: title, + description: description, + primaryButtonTitle: primaryButtonTitle, + onPressed: onPressed + ) + ); +} + +class HBMessageBox extends StatelessWidget { + + final String title; + final String description; + final String primaryButtonTitle; + final FutureOr Function()? onPressed; + + const HBMessageBox({ + super.key, + required this.title, + required this.description, + required this.primaryButtonTitle, + this.onPressed + }); + + @override + Widget build(BuildContext context) { + return Dialog( + elevation: 0.0, + insetPadding: const EdgeInsets.all(HBSpacing.lg), + backgroundColor: HBColors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(HBUIConstants.defaultBorderRadius)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400.0), + child: Padding( + padding: const EdgeInsets.all(HBSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + textAlign: TextAlign.center, + style: HBTypography.base.copyWith( + fontSize: 20.0, + fontWeight: FontWeight.w600, + color: HBColors.gray900 + ) + ), + const HBGap.lg(), + Text( + description, + textAlign: TextAlign.center, + style: HBTypography.base.copyWith( + height: 1.5, + fontSize: 14.0, + fontWeight: FontWeight.w400, + color: HBColors.gray500 + ) + ), + const HBGap.xl(), + Row( + children: [ + Expanded( + child: HBMessageBoxButton( + onPressed: onPressed, + title: primaryButtonTitle + ) + ), + const HBGap.md(), + const Expanded(child: HBMessageBoxButton.cancel()) + ] + ) + ] + ) + ) + ) + ); + } +} + +class HBMessageBoxButton extends StatelessWidget { + + final FutureOr Function()? onPressed; + final String title; + final bool primary; + + const HBMessageBoxButton({ + super.key, + this.onPressed, + required this.title, + this.primary = true + }); + + const HBMessageBoxButton.cancel({ super.key }) + : onPressed = null, + title = 'Abbrechen', + primary = false; + + Future _handleOnPressed(BuildContext context) async { + final bool shouldPop = await onPressed?.call() ?? true; + if (context.mounted && context.canPop() && shouldPop) { + final bool returnValue = onPressed != null ? shouldPop : false; + context.pop(returnValue); + } + } + + @override + Widget build(BuildContext context) { + return Conditional.single( + condition: primary, + widget: HBButton.fill( + onPressed: () => _handleOnPressed(context), + title: title + ), + fallback: HBButton.outline( + onPressed: () => _handleOnPressed(context), + title: title + ) + ); + } +} \ No newline at end of file diff --git a/flutter/lib/core/common/widgets/hb_selection_dialog.dart b/flutter/lib/core/common/widgets/hb_selection_dialog.dart new file mode 100644 index 0000000..8022c54 --- /dev/null +++ b/flutter/lib/core/common/widgets/hb_selection_dialog.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + +import 'package:habib_app/core/common/widgets/hb_gap.dart'; +import 'package:habib_app/core/res/theme/colors/hb_colors.dart'; +import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; +import 'package:habib_app/core/res/theme/typography/hb_typography.dart'; +import 'package:habib_app/core/utils/constants/hb_ui_constants.dart'; + +Future showHBSelectionDialog({ + required BuildContext context, + required String title, + required Widget content +}) async { + return await showDialog( + context: context, + builder: (BuildContext context) => HBSelectionDialog( + title: title, + content: content + ) + ); +} + +class HBSelectionDialog extends StatelessWidget { + + final String title; + final Widget content; + + const HBSelectionDialog({ + super.key, + required this.title, + required this.content + }); + + @override + Widget build(BuildContext context) { + return Dialog( + elevation: 0.0, + insetPadding: const EdgeInsets.all(HBSpacing.lg), + backgroundColor: HBColors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(HBUIConstants.defaultBorderRadius)), + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 600.0, + maxWidth: 800.0 + ), + child: SizedBox.expand( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: HBSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: HBSpacing.lg), + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 22.0, + fontWeight: FontWeight.w600, + color: HBColors.gray900 + ) + ) + ), + const HBGap.lg(), + Expanded(child: content) + ] + ) + ) + ) + ) + ); + } +} \ No newline at end of file diff --git a/flutter/lib/core/common/widgets/hb_table.dart b/flutter/lib/core/common/widgets/hb_table.dart index 4924aca..06f6c83 100644 --- a/flutter/lib/core/common/widgets/hb_table.dart +++ b/flutter/lib/core/common/widgets/hb_table.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_conditional/flutter_conditional.dart'; -import 'package:habib_app/core/common/widgets/hb_chip.dart'; +import 'package:habib_app/core/common/widgets/hb_chip.dart'; import 'package:habib_app/core/res/theme/colors/hb_colors.dart'; import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; import 'package:habib_app/core/res/theme/typography/hb_typography.dart'; @@ -90,7 +90,8 @@ class HBTable extends StatelessWidget { width: tableWidth * fractions[columnIndex], child: item ), - HBTableItemType.chip => item + HBTableItemType.chip => item, + HBTableItemType.radioIndicator => item }; }) ) @@ -152,7 +153,8 @@ class HBTableTitle extends StatelessWidget { enum HBTableItemType { text, - chip + chip, + radioIndicator } class HBTableItem extends StatelessWidget { @@ -172,23 +174,28 @@ class HBTableItem extends StatelessWidget { class HBTableText extends HBTableItem { + final void Function()? onPressed; final String text; const HBTableText({ super.key, + this.onPressed, required this.text }) : super(type: HBTableItemType.text); @override Widget build(BuildContext context) { - return Text( - text, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: HBTypography.base.copyWith( - fontSize: 14.0, - fontWeight: FontWeight.w400, - color: HBColors.gray900 + return GestureDetector( + onTap: onPressed, + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 14.0, + fontWeight: FontWeight.w400, + color: HBColors.gray900 + ) ) ); } @@ -207,4 +214,41 @@ class HBTableChip extends HBTableItem { Widget build(BuildContext context) { return chip; } +} + +class HBTableRadioIndicator extends HBTableItem { + + final bool isSelected; + + const HBTableRadioIndicator({ + super.key, + required this.isSelected + }) : super(type: HBTableItemType.radioIndicator); + + @override + Widget build(BuildContext context) { + return Container( + width: 20.0, + height: 20.0, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: HBColors.gray900, + width: 1.5 + ) + ), + child: Conditional.optionalSingle( + condition: isSelected, + widget: Container( + width: 12.0, + height: 12.0, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: HBColors.gray900 + ) + ) + ) + ); + } } \ No newline at end of file diff --git a/flutter/lib/core/common/widgets/hb_text_field.dart b/flutter/lib/core/common/widgets/hb_text_field.dart new file mode 100644 index 0000000..c26d120 --- /dev/null +++ b/flutter/lib/core/common/widgets/hb_text_field.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_conditional/flutter_conditional.dart'; +import 'package:habib_app/core/common/widgets/hb_gap.dart'; + +import 'package:habib_app/core/common/widgets/hb_icon.dart'; +import 'package:habib_app/core/res/hb_icons.dart'; +import 'package:habib_app/core/res/theme/colors/hb_colors.dart'; +import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; +import 'package:habib_app/core/res/theme/typography/hb_typography.dart'; +import 'package:habib_app/core/utils/constants/hb_ui_constants.dart'; + +class HBTextField extends StatelessWidget { + + final String? title; + final TextEditingController? controller; + final TextInputType? inputType; + final HBIcons? icon; + final String? hint; + final bool obscure; + final bool isEnabled; + final double? maxWidth; + final void Function(String)? onChanged; + + const HBTextField({ + this.title, + super.key, + this.controller, + this.inputType, + this.icon, + this.hint, + this.obscure = false, + this.isEnabled = true, + this.maxWidth, + this.onChanged + }); + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title != null) Text( + title!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 16.0, + fontWeight: FontWeight.w400, + color: HBColors.gray900 + ) + ), + if (title != null) const HBGap.md(), + TextField( + controller: controller, + onChanged: onChanged, + autocorrect: false, + enabled: isEnabled, + keyboardType: inputType, + canRequestFocus: isEnabled, + obscureText: obscure, + cursorColor: HBColors.gray900, + style: HBTypography.base.copyWith( + fontSize: 16.0, + fontWeight: FontWeight.w400, + color: HBColors.gray900 + ), + decoration: InputDecoration( + fillColor: HBColors.gray200, + filled: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: HBSpacing.md, + vertical: (HBUIConstants.textFieldHeight - HBUIConstants.textFieldIconSize) / 2.0 + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(HBUIConstants.defaultBorderRadius), + borderSide: const BorderSide( + color: HBColors.gray400, + width: 1.0 + ) + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(HBUIConstants.defaultBorderRadius), + borderSide: const BorderSide( + color: HBColors.gray400, + width: 1.0 + ) + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(HBUIConstants.defaultBorderRadius), + borderSide: const BorderSide( + color: HBColors.gray400, + width: 1.0 + ) + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(HBUIConstants.defaultBorderRadius), + borderSide: const BorderSide( + color: HBColors.gray400, + width: 1.0 + ) + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(HBUIConstants.defaultBorderRadius), + borderSide: const BorderSide( + color: HBColors.gray900, + width: 2.0 + ) + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(HBUIConstants.defaultBorderRadius), + borderSide: const BorderSide( + color: HBColors.gray900, + width: 2.0 + ) + ), + prefixIconConstraints: const BoxConstraints.tightFor(height: HBUIConstants.textFieldIconSize), + prefixIcon: Conditional.optionalSingle( + condition: icon != null, + widget: Padding( + padding: const EdgeInsets.symmetric(horizontal: HBSpacing.md), + child: HBIcon( + icon: icon!, + color: HBColors.gray500 + ) + ) + ), + hintText: hint, + hintStyle: HBTypography.base.copyWith( + fontSize: 16.0, + fontWeight: FontWeight.w400, + color: HBColors.gray500 + ) + ) + ) + ] + ) + ); + } +} \ No newline at end of file diff --git a/flutter/lib/core/common/widgets/sc_text_field.dart b/flutter/lib/core/common/widgets/sc_text_field.dart deleted file mode 100644 index 0c8f56b..0000000 --- a/flutter/lib/core/common/widgets/sc_text_field.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_conditional/flutter_conditional.dart'; - -import 'package:habib_app/core/common/widgets/hb_icon.dart'; -import 'package:habib_app/core/res/hb_icons.dart'; -import 'package:habib_app/core/res/theme/colors/hb_colors.dart'; -import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; -import 'package:habib_app/core/res/theme/typography/hb_typography.dart'; -import 'package:habib_app/core/utils/constants/hb_ui_constants.dart'; - -class HBTextField extends StatelessWidget { - - final TextEditingController? controller; - final TextInputType? inputType; - final HBIcons? icon; - final String? hint; - final bool obscure; - final bool isEnabled; - final double? maxWidth; - final void Function(String)? onChanged; - - const HBTextField({ - super.key, - this.controller, - this.inputType, - this.icon, - this.hint, - this.obscure = false, - this.isEnabled = true, - this.maxWidth, - this.onChanged - }); - - @override - Widget build(BuildContext context) { - return ConstrainedBox( - constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), - child: TextField( - controller: controller, - onChanged: onChanged, - autocorrect: false, - enabled: isEnabled, - keyboardType: inputType, - canRequestFocus: isEnabled, - obscureText: obscure, - cursorColor: HBColors.gray900, - style: HBTypography.base.copyWith( - fontSize: 16.0, - fontWeight: FontWeight.w400, - color: HBColors.gray900 - ), - decoration: InputDecoration( - fillColor: HBColors.gray200, - filled: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: HBSpacing.md, - vertical: (HBUIConstants.textFieldHeight - HBUIConstants.textFieldIconSize) / 2.0 - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(HBUIConstants.defaultBorderRadius), - borderSide: BorderSide.none - ), - errorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(HBUIConstants.defaultBorderRadius), - borderSide: BorderSide.none - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(HBUIConstants.defaultBorderRadius), - borderSide: BorderSide.none - ), - disabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(HBUIConstants.defaultBorderRadius), - borderSide: BorderSide.none - ), - focusedErrorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(HBUIConstants.defaultBorderRadius), - borderSide: BorderSide.none - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(HBUIConstants.defaultBorderRadius), - borderSide: const BorderSide( - color: HBColors.gray900, - width: 2.0 - ) - ), - prefixIconConstraints: const BoxConstraints.tightFor(height: HBUIConstants.textFieldIconSize), - prefixIcon: Conditional.optionalSingle( - condition: icon != null, - widget: Padding( - padding: const EdgeInsets.symmetric(horizontal: HBSpacing.md), - child: HBIcon( - icon: icon!, - color: HBColors.gray500 - ) - ) - ), - hintText: hint, - hintStyle: HBTypography.base.copyWith( - fontSize: 16.0, - fontWeight: FontWeight.w400, - color: HBColors.gray500 - ) - ) - ) - ); - } -} \ No newline at end of file diff --git a/flutter/lib/core/error/custom_exception.dart b/flutter/lib/core/error/custom_exception.dart new file mode 100644 index 0000000..b323f97 --- /dev/null +++ b/flutter/lib/core/error/custom_exception.dart @@ -0,0 +1,24 @@ +enum CustomExceptionCode { + abc +} + +class CustomException implements Exception { + + final CustomExceptionCode code; + + const CustomException(this.code); + + factory CustomException.abc() => const CustomException(CustomExceptionCode.abc); + + String get title { + return switch (code) { + CustomExceptionCode.abc => '' + }; + } + + String get description { + return switch (code) { + CustomExceptionCode.abc => '' + }; + } +} \ No newline at end of file diff --git a/flutter/lib/core/error/error_page.dart b/flutter/lib/core/error/error_page.dart index 8876442..260267b 100644 --- a/flutter/lib/core/error/error_page.dart +++ b/flutter/lib/core/error/error_page.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:habib_app/core/res/theme/colors/hb_colors.dart'; +// TODO + class ErrorPage extends StatelessWidget { const ErrorPage({ super.key }); diff --git a/flutter/lib/core/extensions/datetime_extension.dart b/flutter/lib/core/extensions/datetime_extension.dart index 6e23a9d..b48d36f 100644 --- a/flutter/lib/core/extensions/datetime_extension.dart +++ b/flutter/lib/core/extensions/datetime_extension.dart @@ -1,6 +1,6 @@ extension DateTimeExtension on DateTime { - String toHumanReadable() { + String toHumanReadableDate() { return '${ day.toString().padLeft(2, '0') }.${ month.toString().padLeft(2, '0') }.${ year.toString().padLeft(4, '0') }'; } } \ No newline at end of file diff --git a/flutter/lib/core/extensions/exception_extension.dart b/flutter/lib/core/extensions/exception_extension.dart deleted file mode 100644 index 73e8907..0000000 --- a/flutter/lib/core/extensions/exception_extension.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:flutter/material.dart'; - -extension ExceptionExtension on Exception { - - String title(BuildContext context) { - return 'Unbekannter Fehler'; - } - - String description(BuildContext context) { - return 'Ein unbekannter Fehler ist aufgetreten.'; - } -} \ No newline at end of file diff --git a/flutter/lib/core/extensions/object_extension.dart b/flutter/lib/core/extensions/object_extension.dart new file mode 100644 index 0000000..59c95e7 --- /dev/null +++ b/flutter/lib/core/extensions/object_extension.dart @@ -0,0 +1,17 @@ +import 'package:habib_app/core/common/models/error_details.dart'; +import 'package:habib_app/core/error/custom_exception.dart'; + +extension ObjectExtension on Object { + + String get errorTitle { + if (this is ErrorDetails) return (this as ErrorDetails).error.errorTitle; + if (this is CustomException) return (this as CustomException).title; + return 'Unbekannter Fehler'; + } + + String get errorDescription { + if (this is ErrorDetails) return (this as ErrorDetails).error.errorDescription; + if (this is CustomException) return (this as CustomException).description; + return 'Ein unbekannter Fehler ist aufgetreten.'; + } +} \ No newline at end of file diff --git a/flutter/lib/core/extensions/results_extension.dart b/flutter/lib/core/extensions/results_extension.dart index 0f5242e..c268f8c 100644 --- a/flutter/lib/core/extensions/results_extension.dart +++ b/flutter/lib/core/extensions/results_extension.dart @@ -4,6 +4,10 @@ import 'package:habib_app/core/utils/typedefs.dart'; extension ResultsExtension on Results { + Json toJson() { + return first.fields; + } + List toJsonList() { return map((ResultRow row) => row.fields).toList(); } diff --git a/flutter/lib/core/services/database.dart b/flutter/lib/core/services/database.dart index 6c9f7fe..cca44b7 100644 --- a/flutter/lib/core/services/database.dart +++ b/flutter/lib/core/services/database.dart @@ -1,21 +1,13 @@ import 'package:mysql1/mysql1.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:habib_app/core/utils/core_utils.dart'; import 'package:habib_app/core/extensions/results_extension.dart'; import 'package:habib_app/core/utils/constants/network_constants.dart'; import 'package:habib_app/core/utils/typedefs.dart'; -import 'package:habib_app/core/utils/env.dart'; part 'database.g.dart'; -final ConnectionSettings connectionSettings = ConnectionSettings( - host: Env.mysqlHost, - port: Env.mysqlPort, - user: Env.mysqlUser, - password: Env.mysqlPassword, - db: Env.mysqlDb -); - @riverpod MySqlConnection mySqlConnection(MySqlConnectionRef ref) { throw UnimplementedError(); @@ -36,14 +28,511 @@ class Database { required MySqlConnection connection }) : _connection = connection; + Future getStatisticsForYear(int year) async { + final String query = ''' + WITH months AS ( + SELECT 1 AS month + UNION ALL SELECT 2 + UNION ALL SELECT 3 + UNION ALL SELECT 4 + UNION ALL SELECT 5 + UNION ALL SELECT 6 + UNION ALL SELECT 7 + UNION ALL SELECT 8 + UNION ALL SELECT 9 + UNION ALL SELECT 10 + UNION ALL SELECT 11 + UNION ALL SELECT 12 + ) + SELECT + ( + SELECT + JSON_ARRAYAGG( + JSON_OBJECT( + 'month', months_with_count.month, + 'books_count', months_with_count.books_count + ) + ) + FROM + ( + SELECT + months.month, + COUNT(borrows_in_year.book_id) AS books_count + FROM + months + LEFT JOIN ( + SELECT + MONTH(borrows.created_at) AS month, + borrows.book_id + FROM + borrows + WHERE + YEAR(borrows.created_at) = $year + ) borrows_in_year ON months.month = borrows_in_year.month + GROUP BY + months.month + ORDER BY + months.month + ) AS months_with_count + ) AS number_borrowed_books, + ( + + SELECT + JSON_ARRAYAGG( + JSON_OBJECT( + 'month', months_with_count.month, + 'bought_count', months_with_count.bought_count, + 'not_bought_count', months_with_count.not_bought_count + ) + ) + FROM + ( + SELECT + months.month, + COALESCE(counts_in_year.bought_count, 0) AS bought_count, + COALESCE(counts_in_year.not_bought_count, 0) AS not_bought_count + FROM + months + LEFT JOIN ( + SELECT + MONTH(books.received_at) AS month, + SUM(CASE WHEN books.bought = 0 THEN 1 ELSE 0 END) AS bought_count, + SUM(CASE WHEN books.bought = 1 THEN 1 ELSE 0 END) AS not_bought_count + FROM + books + WHERE + YEAR(books.received_at) = $year + GROUP BY + MONTH(books.received_at) + ) counts_in_year ON months.month = counts_in_year.month + ORDER BY + months.month + ) AS months_with_count + ) AS new_books_bought + '''; + + final Results results = await _connection.query(query); + return results.toJson(); + } + Future> getBooks({ required String searchText, required int currentPage }) async { - String query = ''' + final String query = ''' + SELECT + x.book_id, + x.book_title, + x.book_isbn_10, + x.book_isbn_13, + x.book_edition, + x.authors, + x.categories, + x.book_status, + x.cf_search + FROM + ( + SELECT + books.id AS book_id, + books.title AS book_title, + books.isbn_10 AS book_isbn_10, + books.isbn_13 AS book_isbn_13, + books.edition AS book_edition, + ( + SELECT JSON_ARRAYAGG( + JSON_OBJECT( + 'author_id', authors.id, + 'author_first_name', authors.first_name, + 'author_last_name', authors.last_name, + 'author_title', authors.title + ) + ) + FROM authors + INNER JOIN book_authors ON authors.id = book_authors.author_id + WHERE book_authors.book_id = books.id + ) AS authors, + ( + SELECT JSON_ARRAYAGG( + JSON_OBJECT( + 'category_id', categories.id, + 'category_name', categories.name + ) + ) + FROM categories + INNER JOIN book_categories ON categories.id = book_categories.category_id + WHERE book_categories.book_id = books.id + ) AS categories, + ( + CASE + WHEN EXISTS (SELECT 1 FROM borrows WHERE borrows.book_id = books.id AND borrows.status <> 'returned') THEN 0 + ELSE 1 + END + ) AS book_status, + (LOWER(CONCAT(books.title, ' ', books.isbn_10, ' ', books.isbn_13))) AS cf_search + FROM books + ) AS x + WHERE ( + ${ searchText.isEmpty ? '1 = 1' : CoreUtils.sqlSearchTextFromText(searchText) } + ) + ORDER BY x.book_title + LIMIT ${(currentPage - 1) * NetworkConstants.pageSize}, ${ NetworkConstants.pageSize }; + '''; + final Results results = await _connection.query(query); + return results.toJsonList(); + } + + Future> getCustomers({ required String searchText, required int currentPage }) async { + final String query = ''' + SELECT + x.customer_id, + x.customer_first_name, + x.customer_last_name, + x.customer_title, + x.customer_phone, + x.customer_mobile, + x.address, + x.cf_search + FROM + ( + SELECT + customers.id AS customer_id, + customers.first_name AS customer_first_name, + customers.last_name AS customer_last_name, + customers.title AS customer_title, + customers.phone AS customer_phone, + customers.mobile AS customer_mobile, + ( + JSON_OBJECT( + 'address_id', addresses.id, + 'address_city', addresses.city, + 'address_postal_code', addresses.postal_code, + 'address_street', addresses.street + ) + ) AS address, + (LOWER(CONCAT(customers.first_name, ' ', customers.last_name, ' ', customers.phone, ' ', customers.mobile))) AS cf_search + FROM customers + INNER JOIN addresses ON customers.address_id = addresses.id + ) AS x + WHERE ( + ${ searchText.isEmpty ? '1 = 1' : CoreUtils.sqlSearchTextFromText(searchText) } + ) + ORDER BY x.customer_last_name + LIMIT ${(currentPage - 1) * NetworkConstants.pageSize}, ${ NetworkConstants.pageSize }; + '''; + final Results results = await _connection.query(query); + return results.toJsonList(); + } + + Future> getBorrows({ required String searchText, required int currentPage }) async { + final String query = ''' + SELECT + x.borrow_id, + x.borrow_end_date, + x.borrow_status, + x.book, + x.customer, + x.cf_search + FROM + ( + SELECT + borrows.id AS borrow_id, + borrows.end_date AS borrow_end_date, + ( + CASE + WHEN borrows.status = 'borrowed' AND borrows.end_date < CURRENT_DATE THEN 'exceeded' + ELSE borrows.status + END + ) AS borrow_status, + ( + JSON_OBJECT( + 'book_id', books.id, + 'book_title', books.title, + 'book_edition', books.edition + ) + ) AS book, + ( + JSON_OBJECT( + 'customer_id', customers.id, + 'customer_first_name', customers.first_name, + 'customer_last_name', customers.last_name, + 'customer_title', customers.title + ) + ) AS customer, + (LOWER(CONCAT(borrows.end_date))) AS cf_search + FROM borrows + INNER JOIN books ON borrows.book_id = books.id + INNER JOIN customers ON borrows.customer_id = customers.id + ) AS x + WHERE ( + ${ searchText.isEmpty ? '1 = 1' : CoreUtils.sqlSearchTextFromText(searchText) } + ) + ORDER BY x.borrow_end_date + LIMIT ${(currentPage - 1) * NetworkConstants.pageSize}, ${ NetworkConstants.pageSize }; + '''; + final Results results = await _connection.query(query); + return results.toJsonList(); + } + + Future> getAuthors({ required String searchText, required int currentPage }) async { + final String query = ''' + SELECT + x.author_id, + x.author_first_name, + x.author_last_name, + x.author_title, + x.cf_search + FROM + ( + SELECT + authors.id AS author_id, + authors.first_name AS author_first_name, + authors.last_name AS author_last_name, + authors.title AS author_title, + (LOWER(CONCAT(authors.first_name, ' ', authors.last_name, ' '))) AS cf_search + FROM authors + ) AS x + WHERE ( + ${ searchText.isEmpty ? '1 = 1' : CoreUtils.sqlSearchTextFromText(searchText) } + ) + ORDER BY x.author_last_name + LIMIT ${(currentPage - 1) * NetworkConstants.pageSize}, ${ NetworkConstants.pageSize }; + '''; + final Results results = await _connection.query(query); + return results.toJsonList(); + } + + Future> getPublishers({ required String searchText, required int currentPage }) async { + final String query = ''' + SELECT + x.publisher_id, + x.publisher_name, + x.publisher_city, + x.cf_search + FROM + ( + SELECT + publishers.id AS publisher_id, + publishers.name AS publisher_name, + publishers.city AS publisher_city, + (LOWER(CONCAT(publishers.name, ' ', publishers.city))) AS cf_search + FROM publishers + ) AS x + WHERE ( + ${ searchText.isEmpty ? '1 = 1' : CoreUtils.sqlSearchTextFromText(searchText) } + ) + ORDER BY x.publisher_name + LIMIT ${(currentPage - 1) * NetworkConstants.pageSize}, ${ NetworkConstants.pageSize }; + '''; + final Results results = await _connection.query(query); + return results.toJsonList(); + } + + Future> getCategories({ required String searchText, required int currentPage }) async { + final String query = ''' + SELECT + x.category_id, + x.category_name, + x.cf_search + FROM + ( + SELECT + categories.id AS category_id, + categories.name AS category_name, + (LOWER(CONCAT(categories.name))) AS cf_search + FROM categories + ) AS x + WHERE ( + ${ searchText.isEmpty ? '1 = 1' : CoreUtils.sqlSearchTextFromText(searchText) } + ) + ORDER BY x.category_name + LIMIT ${(currentPage - 1) * NetworkConstants.pageSize}, ${ NetworkConstants.pageSize }; + '''; + final Results results = await _connection.query(query); + return results.toJsonList(); + } + + Future createCustomer({ + required Json addressJson, + required Json customerJson + }) async { + final int? customerId = await _connection.transaction((TransactionContext tx) async { + final String addressQuery = CoreUtils.sqlCreateTextFromJson( + table: 'addresses', + json: addressJson + ); + + final Results addressResults = await tx.query(addressQuery); + final int addressId = addressResults.insertId!; + + customerJson['address_id'] = addressId; + + final String customerQuery = CoreUtils.sqlCreateTextFromJson( + table: 'customers', + json: customerJson + ); + + final Results customerResults = await tx.query(customerQuery); + final int customerId = customerResults.insertId!; + + return customerId; + }); + + return customerId!; + } + + Future createBook({ + required Json bookJson, + required List authorIds, + required List categoryIds, + required List publisherIds + }) async { + final int? bookId = await _connection.transaction((TransactionContext tx) async { + final String bookQuery = CoreUtils.sqlCreateTextFromJson( + table: 'books', + json: bookJson + ); + + final Results bookResults = await tx.query(bookQuery); + final int bookId = bookResults.insertId!; + + final String authorsQuery = CoreUtils.sqlListCreateTextFromJson( + table: 'book_authors', + columns: [ + 'book_id', + 'author_id' + ], + values: authorIds.map((e) => [ + bookId, + e + ]).toList() + ); + + await tx.query(authorsQuery); + + final String categoriesQuery = CoreUtils.sqlListCreateTextFromJson( + table: 'book_categories', + columns: [ + 'book_id', + 'category_id' + ], + values: categoryIds.map((e) => [ + bookId, + e + ]).toList() + ); + + await tx.query(categoriesQuery); + + final String publishersQuery = CoreUtils.sqlListCreateTextFromJson( + table: 'book_publishers', + columns: [ + 'book_id', + 'publisher_id' + ], + values: publisherIds.map((e) => [ + bookId, + e + ]).toList() + ); + + await tx.query(publishersQuery); + + return bookId; + }); + + return bookId!; + } + + Future createAuthor({ + required Json authorJson + }) async { + final String authorQuery = CoreUtils.sqlCreateTextFromJson( + table: 'authors', + json: authorJson + ); + + final Results authorResults = await _connection.query(authorQuery); + final int authorId = authorResults.insertId!; + + return authorId; + } + + Future createPublisher({ + required Json publisherJson + }) async { + final String publisherQuery = CoreUtils.sqlCreateTextFromJson( + table: 'publishers', + json: publisherJson + ); + + final Results publisherResults = await _connection.query(publisherQuery); + final int publisherId = publisherResults.insertId!; + + return publisherId; + } + + Future createCategory({ + required Json categoryJson + }) async { + final String categoryQuery = CoreUtils.sqlCreateTextFromJson( + table: 'categories', + json: categoryJson + ); + + final Results categoryResults = await _connection.query(categoryQuery); + final int categoryId = categoryResults.insertId!; + + return categoryId; + } + + Future createBorrow({ + required Json borrowJson + }) async { + final String borrowQuery = CoreUtils.sqlCreateTextFromJson( + table: 'borrows', + json: borrowJson + ); + + final Results borrowResults = await _connection.query(borrowQuery); + final int borrowId = borrowResults.insertId!; + + return borrowId; + } + + Future getCustomer({ required int customerId }) async { + final String query = ''' + SELECT + customers.id AS customer_id, + customers.first_name AS customer_first_name, + customers.last_name AS customer_last_name, + customers.occupation AS customer_occupation, + customers.title AS customer_title, + customers.phone AS customer_phone, + customers.mobile AS customer_mobile, + ( + JSON_OBJECT( + 'address_id', addresses.id, + 'address_city', addresses.city, + 'address_postal_code', addresses.postal_code, + 'address_street', addresses.street + ) + ) AS address + FROM customers + INNER JOIN addresses ON customers.address_id = addresses.id + WHERE customers.id = $customerId + '''; + final Results results = await _connection.query(query); + return results.toJson(); + } + + Future getBook({ required int bookId }) async { + final String query = ''' SELECT books.id AS book_id, books.title AS book_title, books.isbn_10 AS book_isbn_10, books.isbn_13 AS book_isbn_13, books.edition AS book_edition, + books.publish_date AS book_publish_date, + books.bought AS book_bought, + books.received_at AS book_received_at, ( SELECT JSON_ARRAYAGG( JSON_OBJECT( @@ -69,53 +558,26 @@ class Database { WHERE book_categories.book_id = books.id ) AS categories, ( - CASE - WHEN EXISTS (SELECT 1 FROM borrows WHERE borrows.book_id = books.id AND borrows.status <> 'returned') THEN 0 - ELSE 1 - END - ) AS book_status - FROM books - WHERE ( - ${ searchText.isEmpty ? '1 = 1' : "books.title LIKE '%$searchText%'" } - ) - ORDER BY books.title - LIMIT ${(currentPage - 1) * NetworkConstants.pageSize}, ${ NetworkConstants.pageSize }; - '''; - final Results results = await _connection.query(query); - return results.toJsonList(); - } - - Future> getCustomers({ required String searchText, required int currentPage }) async { - String query = ''' - SELECT - customers.id AS customer_id, - customers.first_name AS customer_first_name, - customers.last_name AS customer_last_name, - customers.title AS customer_title, - customers.phone AS customer_phone, - customers.mobile AS customer_mobile, - ( - JSON_OBJECT( - 'address_id', addresses.id, - 'address_city', addresses.city, - 'address_postal_code', addresses.postal_code, - 'address_street', addresses.street + SELECT JSON_ARRAYAGG( + JSON_OBJECT( + 'publisher_id', publishers.id, + 'publisher_name', publishers.name, + 'publisher_city', publishers.city + ) ) - ) AS address - FROM customers - INNER JOIN addresses ON customers.address_id = addresses.id - WHERE ( - ${ searchText.isEmpty ? '1 = 1' : "customers.first_name LIKE '%$searchText%'" } - ) - ORDER BY customers.last_name - LIMIT ${(currentPage - 1) * NetworkConstants.pageSize}, ${ NetworkConstants.pageSize }; + FROM publishers + INNER JOIN book_publishers ON publishers.id = book_publishers.publisher_id + WHERE book_publishers.book_id = books.id + ) AS publishers + FROM books + WHERE books.id = $bookId '''; final Results results = await _connection.query(query); - return results.toJsonList(); + return results.toJson(); } - Future> getBorrows({ required String searchText, required int currentPage }) async { - String query = ''' + Future getBorrow({ required int borrowId }) async { + final String query = ''' SELECT borrows.id AS borrow_id, borrows.end_date AS borrow_end_date, @@ -141,15 +603,347 @@ class Database { ) ) AS customer FROM borrows - INNER JOIN books ON borrows.book_id = books.id INNER JOIN customers ON borrows.customer_id = customers.id + INNER JOIN books ON borrows.book_id = books.id + WHERE borrows.id = $borrowId + '''; + final Results results = await _connection.query(query); + return results.toJson(); + } + + Future getCategory({ required int categoryId }) async { + final String query = ''' + SELECT + categories.id AS category_id, + categories.name AS category_name + FROM categories + WHERE categories.id = $categoryId + '''; + final Results results = await _connection.query(query); + return results.toJson(); + } + + Future getAuthor({ required int authorId }) async { + final String query = ''' + SELECT + authors.id AS author_id, + authors.title AS author_title, + authors.first_name AS author_first_name, + authors.last_name AS author_last_name + FROM authors + WHERE authors.id = $authorId + '''; + final Results results = await _connection.query(query); + return results.toJson(); + } + + Future getPublisher({ required int publisherId }) async { + final String query = ''' + SELECT + publishers.id AS publisher_id, + publishers.name AS publisher_name, + publishers.city AS publisher_city + FROM publishers + WHERE publishers.id = $publisherId + '''; + final Results results = await _connection.query(query); + return results.toJson(); + } + + Future> getCustomerBorrows({ required int customerId, required String searchText, required int currentPage }) async { + final String query = ''' + SELECT + x.borrow_id, + x.borrow_end_date, + x.borrow_status, + x.book, + x.cf_search + FROM + ( + SELECT + borrows.id AS borrow_id, + borrows.end_date AS borrow_end_date, + ( + CASE + WHEN borrows.status = 'borrowed' AND borrows.end_date < CURRENT_DATE THEN 'exceeded' + ELSE borrows.status + END + ) AS borrow_status, + ( + JSON_OBJECT( + 'book_id', books.id, + 'book_title', books.title, + 'book_edition', books.edition + ) + ) AS book, + (LOWER(CONCAT(borrows.end_date))) AS cf_search + FROM borrows + INNER JOIN books ON borrows.book_id = books.id + INNER JOIN customers ON borrows.customer_id = customers.id + WHERE customers.id = $customerId + ) AS x + WHERE ( + ${ searchText.isEmpty ? '1 = 1' : CoreUtils.sqlSearchTextFromText(searchText) } + ) + ORDER BY x.borrow_end_date + LIMIT ${(currentPage - 1) * NetworkConstants.pageSize}, ${ NetworkConstants.pageSize }; + '''; + final Results results = await _connection.query(query); + return results.toJsonList(); + } + + Future> getBookBorrows({ required int bookId, required String searchText, required int currentPage }) async { + final String query = ''' + SELECT + x.borrow_id, + x.borrow_end_date, + x.borrow_status, + x.customer, + x.cf_search + FROM + ( + SELECT + borrows.id AS borrow_id, + borrows.end_date AS borrow_end_date, + ( + CASE + WHEN borrows.status = 'borrowed' AND borrows.end_date < CURRENT_DATE THEN 'exceeded' + ELSE borrows.status + END + ) AS borrow_status, + ( + JSON_OBJECT( + 'customer_id', customers.id, + 'customer_title', customers.title, + 'customer_first_name', customers.first_name, + 'customer_last_name', customers.last_name + ) + ) AS customer, + (LOWER(CONCAT(borrows.end_date))) AS cf_search + FROM borrows + INNER JOIN books ON borrows.book_id = books.id + INNER JOIN customers ON borrows.customer_id = customers.id + WHERE books.id = $bookId + ) AS x WHERE ( - ${ searchText.isEmpty ? '1 = 1' : "customers.first_name LIKE '%$searchText%'" } + ${ searchText.isEmpty ? '1 = 1' : CoreUtils.sqlSearchTextFromText(searchText) } ) - ORDER BY borrows.created_at + ORDER BY x.borrow_end_date LIMIT ${(currentPage - 1) * NetworkConstants.pageSize}, ${ NetworkConstants.pageSize }; '''; final Results results = await _connection.query(query); return results.toJsonList(); } + + Future updateCustomer( + int customerId, + int addressId, + Json customerJson, + Json addressJson + ) async { + await _connection.transaction((TransactionContext tx) async { + if (customerJson.isNotEmpty) { + final String customerQuery = CoreUtils.sqlUpdateTextFromJson( + table: 'customers', + json: customerJson, + where: 'customers.id = $customerId' + ); + + await tx.query(customerQuery); + } + + if (addressJson.isNotEmpty) { + final String addressQuery = CoreUtils.sqlUpdateTextFromJson( + table: 'addresses', + json: addressJson, + where: 'addresses.id = $addressId' + ); + + await tx.query(addressQuery); + } + }); + } + + Future updateBook({ + required int bookId, + required Json bookJson, + required List removeAuthorIds, + required List addAuthorIds, + required List removeCategoryIds, + required List addCategoryIds, + required List removePublisherIds, + required List addPublisherIds + }) async { + await _connection.transaction((TransactionContext tx) async { + if (bookJson.isNotEmpty) { + final String bookQuery = CoreUtils.sqlUpdateTextFromJson( + table: 'books', + json: bookJson, + where: 'books.id = $bookId' + ); + + await tx.query(bookQuery); + } + + if (removeAuthorIds.isNotEmpty) { + final String removeAuthorsQuery = ''' + DELETE FROM book_authors + WHERE book_authors.book_id = $bookId + AND book_authors.author_id IN (${ removeAuthorIds.join(', ') }) + '''; + + await tx.query(removeAuthorsQuery); + } + + if (addAuthorIds.isNotEmpty) { + final String addAuthorsQuery = CoreUtils.sqlListCreateTextFromJson( + table: 'book_authors', + columns: [ + 'book_id', + 'author_id' + ], + values: addAuthorIds.map((e) => [ + bookId, + e + ]).toList() + ); + + await tx.query(addAuthorsQuery); + } + + if (removeCategoryIds.isNotEmpty) { + final String removeCategoriesQuery = ''' + DELETE FROM book_categories + WHERE book_categories.book_id = $bookId + AND book_categories.category_id IN (${ removeCategoryIds.join(', ') }) + '''; + + await tx.query(removeCategoriesQuery); + } + + if (addCategoryIds.isNotEmpty) { + final String addCategoriesQuery = CoreUtils.sqlListCreateTextFromJson( + table: 'book_categories', + columns: [ + 'book_id', + 'category_id' + ], + values: addCategoryIds.map((e) => [ + bookId, + e + ]).toList() + ); + + await tx.query(addCategoriesQuery); + } + + if (removePublisherIds.isNotEmpty) { + final String removePublishersQuery = ''' + DELETE FROM book_publishers + WHERE book_publishers.book_id = $bookId + AND book_publishers.publisher_id IN (${ removePublisherIds.join(', ') }) + '''; + + await tx.query(removePublishersQuery); + } + + if (addPublisherIds.isNotEmpty) { + final String addPublishersQuery = CoreUtils.sqlListCreateTextFromJson( + table: 'book_publishers', + columns: [ + 'book_id', + 'publisher_id' + ], + values: addPublisherIds.map((e) => [ + bookId, + e + ]).toList() + ); + + await tx.query(addPublishersQuery); + } + }); + } + + Future updateBorrow( + int borrowId, + Json borrowJson + ) async { + final String borrowQuery = CoreUtils.sqlUpdateTextFromJson( + table: 'borrows', + json: borrowJson, + where: 'borrows.id = $borrowId' + ); + + await _connection.query(borrowQuery); + } + + Future updateCategory( + int categoryId, + Json categoryJson + ) async { + final String categoryQuery = CoreUtils.sqlUpdateTextFromJson( + table: 'categories', + json: categoryJson, + where: 'categories.id = $categoryId' + ); + + await _connection.query(categoryQuery); + } + + Future updateAuthor( + int authorId, + Json authorJson + ) async { + final String authorQuery = CoreUtils.sqlUpdateTextFromJson( + table: 'authors', + json: authorJson, + where: 'authors.id = $authorId' + ); + + await _connection.query(authorQuery); + } + + Future updatePublisher( + int publisherId, + Json publisherJson + ) async { + final String publisherQuery = CoreUtils.sqlUpdateTextFromJson( + table: 'publishers', + json: publisherJson, + where: 'publishers.id = $publisherId' + ); + + await _connection.query(publisherQuery); + } + + Future deleteCustomer(int customerId) async { + final String query = 'DELETE FROM customers WHERE customers.id = $customerId'; + await _connection.query(query); + } + + Future deleteBook(int bookId) async { + final String query = 'DELETE FROM books WHERE books.id = $bookId'; + await _connection.query(query); + } + + Future deleteAuthor(int authorId) async { + final String query = 'DELETE FROM authors WHERE authors.id = $authorId'; + await _connection.query(query); + } + + Future deletePublisher(int publisherId) async { + final String query = 'DELETE FROM publishers WHERE publishers.id = $publisherId'; + await _connection.query(query); + } + + Future deleteCategory(int categoryId) async { + final String query = 'DELETE FROM categories WHERE categories.id = $categoryId'; + await _connection.query(query); + } + + Future deleteBorrow(int borrowId) async { + final String query = 'DELETE FROM borrows WHERE borrows.id = $borrowId'; + await _connection.query(query); + } } \ No newline at end of file diff --git a/flutter/lib/core/services/preferences.dart b/flutter/lib/core/services/preferences.dart new file mode 100644 index 0000000..e542ab6 --- /dev/null +++ b/flutter/lib/core/services/preferences.dart @@ -0,0 +1,99 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +part 'preferences.g.dart'; + +@riverpod +SharedPreferences sharedPreferences(SharedPreferencesRef ref) { + throw UnimplementedError(); +} + +@riverpod +Preferences preferences(PreferencesRef ref) { + return Preferences( + sharedPreferences: ref.read(sharedPreferencesProvider) + ); +} + +class Preferences { + + final SharedPreferences _sharedPreferences; + + const Preferences({ + required SharedPreferences sharedPreferences + }) : _sharedPreferences = sharedPreferences; + + static const String _mySqlHostKey = 'mysql-host'; + static const String _mySqlPortKey = 'mysql-port'; + static const String _mySqlUserKey = 'mysql-user'; + static const String _mySqlPasswordKey = 'mysql-password'; + static const String _mySqlDbKey = 'mysql-db'; + + Future setMySqlHost(String host) async { + try { + return await _sharedPreferences.setString(_mySqlHostKey, host); + } catch (_) { + return false; + } + } + + String? getMySqlHost() { + return _sharedPreferences.getString(_mySqlHostKey); + } + + Future setMySqlPort(int port) async { + try { + return await _sharedPreferences.setInt(_mySqlPortKey, port); + } catch (_) { + return false; + } + } + + int? getMySqlPort() { + return _sharedPreferences.getInt(_mySqlPortKey); + } + + Future setMySqlUser(String user) async { + try { + return await _sharedPreferences.setString(_mySqlUserKey, user); + } catch (_) { + return false; + } + } + + String? getMySqlUser() { + return _sharedPreferences.getString(_mySqlUserKey); + } + + Future setMySqlPassword(String password) async { + try { + return await _sharedPreferences.setString(_mySqlPasswordKey, password); + } catch (_) { + return false; + } + } + + String? getMySqlPassword() { + return _sharedPreferences.getString(_mySqlPasswordKey); + } + + Future setMySqlDb(String db) async { + try { + return await _sharedPreferences.setString(_mySqlDbKey, db); + } catch (_) { + return false; + } + } + + String? getMySqlDb() { + return _sharedPreferences.getString(_mySqlDbKey); + } + + bool get connectionSettingsComplete { + return getMySqlHost() != null + && getMySqlPort() != null + && getMySqlUser() != null + && getMySqlPassword() != null + && getMySqlDb() != null; + } +} diff --git a/flutter/lib/core/services/routes.dart b/flutter/lib/core/services/routes.dart index e9a57c7..583cbb1 100644 --- a/flutter/lib/core/services/routes.dart +++ b/flutter/lib/core/services/routes.dart @@ -2,8 +2,18 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:habib_app/src/features/home/presentation/pages/home_page.dart'; +import 'package:habib_app/src/features/categories/presentation/pages/categories_page.dart'; +import 'package:habib_app/src/features/categories/presentation/pages/category_details_page.dart'; +import 'package:habib_app/src/features/categories/presentation/pages/create_category_page.dart'; import 'package:habib_app/core/common/widgets/hb_dialog.dart'; import 'package:habib_app/core/error/error_page.dart'; +import 'package:habib_app/src/features/publishers/presentation/pages/create_publisher_page.dart'; +import 'package:habib_app/src/features/publishers/presentation/pages/publisher_details_page.dart'; +import 'package:habib_app/src/features/publishers/presentation/pages/publishers_page.dart'; +import 'package:habib_app/src/features/authors/presentation/pages/authors_page.dart'; +import 'package:habib_app/src/features/authors/presentation/pages/author_details_page.dart'; +import 'package:habib_app/src/features/authors/presentation/pages/create_author_page.dart'; import 'package:habib_app/src/features/books/presentation/pages/book_details_page.dart'; import 'package:habib_app/src/features/books/presentation/pages/books_page.dart'; import 'package:habib_app/src/features/books/presentation/pages/create_book_page.dart'; @@ -13,7 +23,6 @@ import 'package:habib_app/src/features/borrows/presentation/pages/create_borrow_ import 'package:habib_app/src/features/customers/presentation/pages/create_customer_page.dart'; import 'package:habib_app/src/features/customers/presentation/pages/customer_details_page.dart'; import 'package:habib_app/src/features/customers/presentation/pages/customers_page.dart'; -import 'package:habib_app/src/features/home/presentation/pages/home_page.dart'; import 'package:habib_app/src/features/settings/presentation/pages/settings_page.dart'; import 'package:habib_app/src/main_page.dart'; @@ -52,6 +61,9 @@ class SplashRoute extends GoRouteData { TypedGoRoute(path: HomeRoute.location), TypedGoRoute(path: CustomersRoute.location), TypedGoRoute(path: BooksRoute.location), + TypedGoRoute(path: AuthorsRoute.location), + TypedGoRoute(path: PublishersRoute.location), + TypedGoRoute(path: CategoriesRoute.location), TypedGoRoute(path: BorrowsRoute.location), TypedGoRoute(path: SettingsRoute.location) ] @@ -191,6 +203,168 @@ class BookDetailsRoute extends GoRouteData { } } +class AuthorsRoute extends GoRouteData { + + const AuthorsRoute(); + + static final GlobalKey $navigatorKey = shellNavigatorKey; + + static const String location = '/authors'; + + @override + Page buildPage(BuildContext context, GoRouterState state) { + return NoTransitionPage( + key: state.pageKey, + child: const AuthorsPage() + ); + } +} + +@TypedGoRoute(path: CreateAuthorRoute.location) +class CreateAuthorRoute extends GoRouteData { + + const CreateAuthorRoute(); + + static final GlobalKey $navigatorKey = rootNavigatorKey; + + static const String location = '/authors/new'; + + @override + Page buildPage(BuildContext context, GoRouterState state) { + return HBDialogPage( + builder: (BuildContext context) => const CreateAuthorPage() + ); + } +} + +@TypedGoRoute(path: AuthorDetailsRoute.location) +class AuthorDetailsRoute extends GoRouteData { + + final int authorId; + + const AuthorDetailsRoute({ + required this.authorId + }); + + static final GlobalKey $navigatorKey = rootNavigatorKey; + + static const String location = '/authors/:authorId'; + + @override + Widget build(BuildContext context, GoRouterState state) { + final AuthorDetailsPageParams params = AuthorDetailsPageParams(authorId: authorId); + return AuthorDetailsPage(params: params); + } +} + +class PublishersRoute extends GoRouteData { + + const PublishersRoute(); + + static final GlobalKey $navigatorKey = shellNavigatorKey; + + static const String location = '/publishers'; + + @override + Page buildPage(BuildContext context, GoRouterState state) { + return NoTransitionPage( + key: state.pageKey, + child: const PublishersPage() + ); + } +} + +@TypedGoRoute(path: CreatePublisherRoute.location) +class CreatePublisherRoute extends GoRouteData { + + const CreatePublisherRoute(); + + static final GlobalKey $navigatorKey = rootNavigatorKey; + + static const String location = '/publishers/new'; + + @override + Page buildPage(BuildContext context, GoRouterState state) { + return HBDialogPage( + builder: (BuildContext context) => const CreatePublisherPage() + ); + } +} + +@TypedGoRoute(path: PublisherDetailsRoute.location) +class PublisherDetailsRoute extends GoRouteData { + + final int publisherId; + + const PublisherDetailsRoute({ + required this.publisherId + }); + + static final GlobalKey $navigatorKey = rootNavigatorKey; + + static const String location = '/publishers/:publisherId'; + + @override + Widget build(BuildContext context, GoRouterState state) { + final PublisherDetailsPageParams params = PublisherDetailsPageParams(publisherId: publisherId); + return PublisherDetailsPage(params: params); + } +} + +class CategoriesRoute extends GoRouteData { + + const CategoriesRoute(); + + static final GlobalKey $navigatorKey = shellNavigatorKey; + + static const String location = '/categories'; + + @override + Page buildPage(BuildContext context, GoRouterState state) { + return NoTransitionPage( + key: state.pageKey, + child: const CategoriesPage() + ); + } +} + +@TypedGoRoute(path: CreateCategoryRoute.location) +class CreateCategoryRoute extends GoRouteData { + + const CreateCategoryRoute(); + + static final GlobalKey $navigatorKey = rootNavigatorKey; + + static const String location = '/categories/new'; + + @override + Page buildPage(BuildContext context, GoRouterState state) { + return HBDialogPage( + builder: (BuildContext context) => const CreateCategoryPage() + ); + } +} + +@TypedGoRoute(path: CategoryDetailsRoute.location) +class CategoryDetailsRoute extends GoRouteData { + + final int categoryId; + + const CategoryDetailsRoute({ + required this.categoryId + }); + + static final GlobalKey $navigatorKey = rootNavigatorKey; + + static const String location = '/categories/:categoryId'; + + @override + Widget build(BuildContext context, GoRouterState state) { + final CategoryDetailsPageParams params = CategoryDetailsPageParams(categoryId: categoryId); + return CategoryDetailsPage(params: params); + } +} + class BorrowsRoute extends GoRouteData { const BorrowsRoute(); @@ -211,7 +385,9 @@ class BorrowsRoute extends GoRouteData { @TypedGoRoute(path: CreateBorrowRoute.location) class CreateBorrowRoute extends GoRouteData { - const CreateBorrowRoute(); + final CreateBorrowPageParams? $extra; + + const CreateBorrowRoute({ this.$extra }); static final GlobalKey $navigatorKey = rootNavigatorKey; @@ -220,7 +396,7 @@ class CreateBorrowRoute extends GoRouteData { @override Page buildPage(BuildContext context, GoRouterState state) { return HBDialogPage( - builder: (BuildContext context) => const CreateBorrowPage() + builder: (BuildContext context) => CreateBorrowPage(params: $extra) ); } } diff --git a/flutter/lib/core/utils/core_utils.dart b/flutter/lib/core/utils/core_utils.dart index aabfcdc..e478f3c 100644 --- a/flutter/lib/core/utils/core_utils.dart +++ b/flutter/lib/core/utils/core_utils.dart @@ -3,6 +3,7 @@ import 'package:flutter/scheduler.dart'; import 'package:toastification/toastification.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; import 'package:habib_app/core/res/theme/typography/hb_typography.dart'; import 'package:habib_app/core/res/theme/colors/hb_colors.dart'; import 'package:habib_app/core/utils/constants/hb_ui_constants.dart'; @@ -55,4 +56,90 @@ abstract class CoreUtils { ); }); } + + static String sqlSearchTextFromText(String searchText) { + final List list = searchText.split(' '); + String res = ''; + + for (String listItem in list) { + if (res.isNotEmpty) res += ' AND '; + res += "x.cf_search LIKE '%${ listItem.toLowerCase() }%'"; + } + + return res; + } + + static String sqlCreateTextFromJson({ + required String table, + required Json json + }) { + return 'INSERT INTO $table (${ json.keys.join(', ') }) VALUES (${ json.values.join(', ') })'; + } + + static String sqlUpdateTextFromJson({ + required String table, + required Json json, + String? where + }) { + String res = 'UPDATE $table SET '; + + for (int i = 0; i < json.entries.length; i++) { + final MapEntry entry = json.entries.elementAt(i); + res += '$table.${ entry.key } = ${ entry.value }'; + if (i != json.entries.length - 1) { + res += ', '; + } + } + + if (where != null) res += ' WHERE $where'; + + return res; + } + + static String sqlListCreateTextFromJson({ + required String table, + required List columns, + required List> values + }) { + String res = 'INSERT INTO $table (${ columns.join(', ') }) VALUES '; + + res += values.map((e) => '(${ e.join(', ') })').join(', '); + + return res; + } + + static String textFromMonth(int number) { + return switch (number) { + 1 => 'JAN', + 2 => 'FEB', + 3 => 'MÄR', + 4 => 'APR', + 5 => 'MAI', + 6 => 'JUN', + 7 => 'JUL', + 8 => 'AUG', + 9 => 'SEP', + 10 => 'OKT', + 11 => 'NOV', + 12 => 'DEZ', + _ => throw ArgumentError('Month not found for number: $number') + }; + } + + static (List list1, List list2) findUniqueElements(List list1, List list2) { + Set set1 = list1.toSet(); + Set set2 = list2.toSet(); + + List onlyInList1 = set1.difference(set2).toList(); + List onlyInList2 = set2.difference(set1).toList(); + + return (onlyInList1, onlyInList2); + } + + static bool intListsHaveSameContents(List list1, List list2) { + Set set1 = list1.toSet(); + Set set2 = list2.toSet(); + + return set1 == set2; + } } \ No newline at end of file diff --git a/flutter/lib/core/utils/result.dart b/flutter/lib/core/utils/result.dart index 4ca36ed..41421fa 100644 --- a/flutter/lib/core/utils/result.dart +++ b/flutter/lib/core/utils/result.dart @@ -1,18 +1,20 @@ +import 'dart:async'; + sealed class Result { const Result(); factory Result.success(S value) => Success(value); - factory Result.failure(Exception exception, { StackTrace? stackTrace }) => Failure(exception, stackTrace: stackTrace); + factory Result.failure(Object error, { StackTrace? stackTrace }) => Failure(error, stackTrace: stackTrace); - R fold({ - required R Function(S value) onSuccess, - required R Function(Exception exception, StackTrace stackTrace) onFailure - }) { + FutureOr fold({ + required FutureOr Function(S value) onSuccess, + required FutureOr Function(Object error, StackTrace stackTrace) onFailure + }) async { if (this is Success) { - return onSuccess((this as Success).value); + return await onSuccess((this as Success).value); } else if (this is Failure) { - return onFailure((this as Failure).exception, (this as Failure).stackTrace); + return await onFailure((this as Failure).error, (this as Failure).stackTrace); } else { throw Exception('Unexpected Result type'); } @@ -27,9 +29,9 @@ final class Success extends Result { final class Failure extends Result { - final Exception exception; + final Object error; final StackTrace stackTrace; - Failure(this.exception, { StackTrace? stackTrace }) + Failure(this.error, { StackTrace? stackTrace }) : stackTrace = stackTrace ?? StackTrace.current; } \ No newline at end of file diff --git a/flutter/lib/core/utils/validator.dart b/flutter/lib/core/utils/validator.dart new file mode 100644 index 0000000..0759cb1 --- /dev/null +++ b/flutter/lib/core/utils/validator.dart @@ -0,0 +1,170 @@ +import 'package:habib_app/core/utils/enums/borrow_status.dart'; +import 'package:habib_app/src/features/settings/presentation/app/settings_page_notifier.dart'; + +class Validator { + + static void validateSettings(Settings settings) { + final String? mySqlHost = settings.mySqlHost; + if (mySqlHost == null || mySqlHost.isEmpty) throw Exception('Der Host darf nicht leer sein.'); + + final int? mySqlPort = settings.mySqlPort; + if (mySqlPort == null) throw Exception('Der Port darf nicht leer sein.'); + + final String? mySqlUser = settings.mySqlUser; + if (mySqlUser == null || mySqlUser.isEmpty) throw Exception('Der Benutzer darf nicht leer sein.'); + + final String? mySqlPassword = settings.mySqlPassword; + if (mySqlPassword == null || mySqlPassword.isEmpty) throw Exception('Das Passwort darf nicht leer sein.'); + + final String? mySqlDb = settings.mySqlDb; + if (mySqlDb == null || mySqlDb.isEmpty) throw Exception('Die Datenbank darf nicht leer sein.'); + } + + static void validateCustomerCreate({ + String? firstName, + String? lastName, + String? title, + String? occupation, + String? phone, + String? mobile, + String? addressPostalCode, + String? addressCity, + String? addressStreet + }) { + if (firstName == null || firstName.isEmpty || firstName.length > 100) throw Exception('Der Vorname muss zwischen 1 und 100 Zeichen haben.'); + if (lastName == null || lastName.isEmpty || lastName.length > 100) throw Exception('Der Nachname muss zwischen 1 und 100 Zeichen haben.'); + if (title != null && title.length > 50) throw Exception('Der Titel darf nicht länger als 50 Zeichen sein.'); + if (occupation != null && occupation.length > 100) throw Exception('Der Beruf darf nicht länger als 100 Zeichen sein.'); + if (phone != null && phone.length > 20) throw Exception('Die Telefonnummer darf nicht länger als 20 Zeichen sein.'); + if (mobile != null && mobile.length > 20) throw Exception('Die Mobilnummer darf nicht länger als 20 Zeichen sein.'); + if (addressCity == null || addressCity.isEmpty || addressCity.length > 100) throw Exception('Die Stadt muss zwischen 1 und 100 Zeichen haben.'); + if (addressPostalCode == null || addressPostalCode.length != 5) throw Exception('Die Postleitzahl muss genau 5 Zeichen lang sein.'); + if (addressStreet == null || addressStreet.isEmpty || addressStreet.length > 100) throw Exception('Die Straße und Hausnummer müssen zusammen zwischen 1 und 100 Zeichen lang sein.'); + } + + static void validateCustomerUpdate({ + String? firstName, + String? lastName, + String? title, + String? occupation, + String? phone, + String? mobile, + String? addressPostalCode, + String? addressCity, + String? addressStreet + }) { + if (firstName == null || firstName.isEmpty || firstName.length > 100) throw Exception('Der Vorname muss zwischen 1 und 100 Zeichen haben.'); + if (lastName == null || lastName.isEmpty || lastName.length > 100) throw Exception('Der Nachname muss zwischen 1 und 100 Zeichen haben.'); + if (title != null && title.length > 50) throw Exception('Der Titel darf nicht länger als 50 Zeichen sein.'); + if (occupation != null && occupation.length > 100) throw Exception('Der Beruf darf nicht länger als 100 Zeichen sein.'); + if (phone != null && phone.length > 20) throw Exception('Die Telefonnummer darf nicht länger als 20 Zeichen sein.'); + if (mobile != null && mobile.length > 20) throw Exception('Die Mobilnummer darf nicht länger als 20 Zeichen sein.'); + if (addressCity == null || addressCity.isEmpty || addressCity.length > 100) throw Exception('Die Stadt muss zwischen 1 und 100 Zeichen haben.'); + if (addressPostalCode == null || addressPostalCode.length != 5) throw Exception('Die Postleitzahl muss genau 5 Zeichen lang sein.'); + if (addressStreet == null || addressStreet.isEmpty || addressStreet.length > 100) throw Exception('Die Straße und Hausnummer müssen zusammen zwischen 1 und 100 Zeichen lang sein.'); + } + + static void validateAuthorCreate({ + String? firstName, + String? lastName, + String? title + }) { + if (firstName == null || firstName.isEmpty || firstName.length > 100) throw Exception('Der Vorname muss zwischen 1 und 100 Zeichen haben.'); + if (lastName == null || lastName.isEmpty || lastName.length > 100) throw Exception('Der Nachname muss zwischen 1 und 100 Zeichen haben.'); + if (title != null && title.length > 50) throw Exception('Der Titel darf nicht länger als 50 Zeichen sein.'); + } + + static void validateCategoryCreate({ + String? name + }) { + if (name == null || name.isEmpty || name.length > 100) throw Exception('Der Name muss zwischen 1 und 100 Zeichen haben.'); + } + + static void validatePublisherCreate({ + String? name, + String? city + }) { + if (name == null || name.isEmpty || name.length > 100) throw Exception('Der Name muss zwischen 1 und 100 Zeichen haben.'); + if (city == null || city.isEmpty || city.length > 100) throw Exception('Die Stadt muss zwischen 1 und 100 Zeichen haben.'); + } + + static void validateCategoryUpdate({ + String? name + }) { + if (name == null || name.isEmpty || name.length > 100) throw Exception('Der Name muss zwischen 1 und 100 Zeichen haben.'); + } + + static void validateAuthorUpdate({ + String? firstName, + String? lastName, + String? title + }) { + if (firstName == null || firstName.isEmpty || firstName.length > 100) throw Exception('Der Vorname muss zwischen 1 und 100 Zeichen haben.'); + if (lastName == null || lastName.isEmpty || lastName.length > 100) throw Exception('Der Nachname muss zwischen 1 und 100 Zeichen haben.'); + if (title != null && title.length > 50) throw Exception('Der Titel darf nicht länger als 50 Zeichen sein.'); + } + + static void validatePublisherUpdate({ + String? name, + String? city + }) { + if (name == null || name.isEmpty || name.length > 100) throw Exception('Der Name muss zwischen 1 und 100 Zeichen haben.'); + if (city == null || city.isEmpty || city.length > 100) throw Exception('Die Stadt muss zwischen 1 und 100 Zeichen haben.'); + } + + static void validateBookCreate({ + String? title, + String? isbn10, + String? isbn13, + List? authorIds, + List? categoriesIds, + int? edition, + DateTime? publishDate, + List? publisherIds, + bool? bought, + DateTime? receivedAt + }) { + if (title == null || title.isEmpty || title.length > 255) throw Exception('Der Titel muss zwischen 1 und 255 Zeichen haben.'); + if (isbn10 != null && isbn10.length != 10) throw Exception('Die ISBN10 muss genau 10 Zeichen lang sein.'); + if (isbn13 != null && isbn13.length != 13) throw Exception('Die ISBN13 muss genau 10 Zeichen lang sein.'); + if ((authorIds ?? []).isEmpty) throw Exception('Es muss mindestens einen Autor geben.'); + if ((categoriesIds ?? []).isEmpty) throw Exception('Es muss mindestens eine Kategorie geben.'); + if (publishDate != null && publishDate.isAfter(DateTime.now())) throw Exception('Das Veröffentlichungsdatum darf nicht in der Zukunft liegen.'); + if ((publisherIds ?? []).isEmpty) throw Exception('Es muss mindestens einen Verlag geben.'); + if (receivedAt != null && receivedAt.isAfter(DateTime.now())) throw Exception('Das Erhaltungsdatum darf nicht in der Zukunft liegen.'); + } + + static void validateBookUpdate({ + String? title, + String? isbn10, + String? isbn13, + List? authorIds, + List? categoriesIds, + int? edition, + DateTime? publishDate, + List? publisherIds, + bool? bought, + DateTime? receivedAt + }) { + if (title == null || title.isEmpty || title.length > 255) throw Exception('Der Titel muss zwischen 1 und 255 Zeichen haben.'); + if (isbn10 != null && isbn10.length != 10) throw Exception('Die ISBN10 muss genau 10 Zeichen lang sein.'); + if (isbn13 != null && isbn13.length != 13) throw Exception('Die ISBN13 muss genau 10 Zeichen lang sein.'); + if ((authorIds ?? []).isEmpty) throw Exception('Es muss mindestens einen Autor geben.'); + if ((categoriesIds ?? []).isEmpty) throw Exception('Es muss mindestens eine Kategorie geben.'); + if (publishDate != null && publishDate.isAfter(DateTime.now())) throw Exception('Das Veröffentlichungsdatum darf nicht in der Zukunft liegen.'); + if ((publisherIds ?? []).isEmpty) throw Exception('Es muss mindestens einen Verlag geben.'); + if (receivedAt != null && receivedAt.isAfter(DateTime.now())) throw Exception('Das Erhaltungsdatum darf nicht in der Zukunft liegen.'); + } + + static void validateBorrowUpdate({ + DateTime? endDate, + BorrowStatus? status + }) { + if (endDate == null) throw Exception('Das Rückgabedatum darf nicht leer sein.'); + } + + static void validateBorrowCreate({ + DateTime? endDate, + BorrowStatus? status + }) { } +} \ No newline at end of file diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index a230755..c6ed1a4 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -3,19 +3,37 @@ import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mysql1/mysql1.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:habib_app/core/services/preferences.dart'; import 'package:habib_app/core/services/database.dart'; import 'package:habib_app/src/app.dart'; Future main() async { await dotenv.load(); - - final MySqlConnection connection = await MySqlConnection.connect(connectionSettings); + + final SharedPreferences sharedPreferences = await SharedPreferences.getInstance(); + final Preferences preferences = Preferences(sharedPreferences: sharedPreferences); + + final ConnectionSettings connectionSettings = ConnectionSettings( + host: preferences.getMySqlHost() ?? '', + port: preferences.getMySqlPort() ?? 0, + user: preferences.getMySqlUser(), + password: preferences.getMySqlPassword(), + db: preferences.getMySqlDb() + ); + + MySqlConnection? connection; + try { + connection = await MySqlConnection.connect(connectionSettings); + } catch (_) { } runApp( ProviderScope( overrides: [ - mySqlConnectionProvider.overrideWithValue(connection) + sharedPreferencesProvider.overrideWithValue(sharedPreferences), + preferencesProvider.overrideWithValue(preferences), + if (connection != null) mySqlConnectionProvider.overrideWithValue(connection) ], child: const App() ) diff --git a/flutter/lib/src/features/authors/data/datasources/author_datasource.dart b/flutter/lib/src/features/authors/data/datasources/author_datasource.dart new file mode 100644 index 0000000..b253ecf --- /dev/null +++ b/flutter/lib/src/features/authors/data/datasources/author_datasource.dart @@ -0,0 +1,36 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/authors/data/dto/author_details_dto.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/authors/data/datasources/author_datasource_impl.dart'; +import 'package:habib_app/src/features/authors/data/dto/author_dto.dart'; +import 'package:habib_app/core/services/database.dart'; + +part 'author_datasource.g.dart'; + +@riverpod +AuthorDatasource authorDatasource(AuthorDatasourceRef ref) { + return AuthorDatasourceImpl( + database: ref.read(databaseProvider) + ); +} + +abstract interface class AuthorDatasource { + + const AuthorDatasource(); + + Future> getAuthors({ required String searchText, required int currentPage }); + + Future createAuthor({ + required Json authorJson + }); + + Future getAuthor({ required int authorId }); + + Future updateAuthor({ + required int authorId, + required Json authorJson + }); + + Future deleteAuthor({ required int authorId }); +} \ No newline at end of file diff --git a/flutter/lib/src/features/authors/data/datasources/author_datasource_impl.dart b/flutter/lib/src/features/authors/data/datasources/author_datasource_impl.dart new file mode 100644 index 0000000..e4eb3a3 --- /dev/null +++ b/flutter/lib/src/features/authors/data/datasources/author_datasource_impl.dart @@ -0,0 +1,52 @@ +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/core/services/database.dart'; +import 'package:habib_app/src/features/authors/data/datasources/author_datasource.dart'; +import 'package:habib_app/src/features/authors/data/dto/author_details_dto.dart'; +import 'package:habib_app/src/features/authors/data/dto/author_dto.dart'; + +class AuthorDatasourceImpl implements AuthorDatasource { + + final Database _database; + + const AuthorDatasourceImpl({ + required Database database + }) : _database = database; + + @override + Future> getAuthors({ required String searchText, required int currentPage }) async { + final List jsonList = await _database.getAuthors( + searchText: searchText, + currentPage: currentPage + ); + return AuthorDto.listFromJsonList(jsonList); + } + + @override + Future createAuthor({ + required Json authorJson + }) async { + return await _database.createAuthor(authorJson: authorJson); + } + + @override + Future getAuthor({ required int authorId }) async { + final Json json = await _database.getAuthor(authorId: authorId); + return AuthorDetailsDto.fromJson(json); + } + + @override + Future updateAuthor({ + required int authorId, + required Json authorJson + }) async { + return await _database.updateAuthor( + authorId, + authorJson + ); + } + + @override + Future deleteAuthor({ required int authorId }) async { + return await _database.deleteAuthor(authorId); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/authors/data/dto/author_details_dto.dart b/flutter/lib/src/features/authors/data/dto/author_details_dto.dart new file mode 100644 index 0000000..f09534e --- /dev/null +++ b/flutter/lib/src/features/authors/data/dto/author_details_dto.dart @@ -0,0 +1,21 @@ +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/authors/domain/entities/author_details_entity.dart'; + +class AuthorDetailsDto extends AuthorDetailsEntity { + + const AuthorDetailsDto({ + required super.id, + super.title, + required super.firstName, + required super.lastName + }); + + factory AuthorDetailsDto.fromJson(Json authorJson) { + return AuthorDetailsDto( + id: authorJson['author_id'] as int, + title: authorJson['author_title'] as String?, + firstName: authorJson['author_first_name'] as String, + lastName: authorJson['author_last_name'] as String + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/authors/data/dto/author_dto.dart b/flutter/lib/src/features/authors/data/dto/author_dto.dart new file mode 100644 index 0000000..d529174 --- /dev/null +++ b/flutter/lib/src/features/authors/data/dto/author_dto.dart @@ -0,0 +1,25 @@ +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/authors/domain/entities/author_entity.dart'; + +class AuthorDto extends AuthorEntity { + + const AuthorDto({ + required super.id, + required super.firstName, + required super.lastName, + super.title + }); + + factory AuthorDto.fromJson(Json authorJson) { + return AuthorDto( + id: authorJson['author_id'] as int, + firstName: authorJson['author_first_name'] as String, + lastName: authorJson['author_last_name'] as String, + title: authorJson['author_title'] as String? + ); + } + + static List listFromJsonList(List jsonList) { + return jsonList.map((Json json) => AuthorDto.fromJson(json)).toList(); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/authors/data/repositories/author_repository_impl.dart b/flutter/lib/src/features/authors/data/repositories/author_repository_impl.dart new file mode 100644 index 0000000..8f58e26 --- /dev/null +++ b/flutter/lib/src/features/authors/data/repositories/author_repository_impl.dart @@ -0,0 +1,78 @@ +import 'package:habib_app/core/utils/result.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/authors/data/datasources/author_datasource.dart'; +import 'package:habib_app/src/features/authors/data/dto/author_details_dto.dart'; +import 'package:habib_app/src/features/authors/data/dto/author_dto.dart'; +import 'package:habib_app/src/features/authors/domain/entities/author_details_entity.dart'; +import 'package:habib_app/src/features/authors/domain/repositories/author_repository.dart'; +import 'package:habib_app/src/features/authors/domain/entities/author_entity.dart'; + +class AuthorRepositoryImpl implements AuthorRepository { + + final AuthorDatasource _authorDatasource; + + const AuthorRepositoryImpl({ + required AuthorDatasource authorDatasource + }) : _authorDatasource = authorDatasource; + + @override + ResultFuture> getAuthors({ required String searchText, required int currentPage }) async { + try { + final List result = await _authorDatasource.getAuthors( + searchText: searchText, + currentPage: currentPage + ); + return Success(result); + } catch (e) { + return Failure(e); + } + } + + @override + ResultFuture createAuthor({ + required Json authorJson + }) async { + try { + final int authorId = await _authorDatasource.createAuthor(authorJson: authorJson); + return Success(authorId); + } catch (e) { + return Failure(e); + } + } + + @override + ResultFuture getAuthor({ required int authorId }) async { + try { + final AuthorDetailsDto result = await _authorDatasource.getAuthor(authorId: authorId); + return Success(result); + } catch (e) { + return Failure(e); + } + } + + @override + ResultFuture updateAuthor({ + required int authorId, + required Json authorJson + }) async { + try { + await _authorDatasource.updateAuthor( + authorId: authorId, + authorJson: authorJson + ); + return const Success(null); + } catch (e) { + return Failure(e); + } + } + + @override + ResultFuture deleteAuthor({ required int authorId }) async { + try { + await _authorDatasource.deleteAuthor(authorId: authorId); + return const Success(null); + } catch (e) { + return Failure(e); + } + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/authors/domain/entities/author_details_entity.dart b/flutter/lib/src/features/authors/domain/entities/author_details_entity.dart new file mode 100644 index 0000000..6ba486c --- /dev/null +++ b/flutter/lib/src/features/authors/domain/entities/author_details_entity.dart @@ -0,0 +1,24 @@ +import 'package:equatable/equatable.dart'; + +class AuthorDetailsEntity extends Equatable { + + final int id; + final String? title; + final String firstName; + final String lastName; + + const AuthorDetailsEntity({ + required this.id, + this.title, + required this.firstName, + required this.lastName + }); + + @override + List get props => [ + id, + title, + firstName, + lastName + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/books/domain/entities/author_entity.dart b/flutter/lib/src/features/authors/domain/entities/author_entity.dart similarity index 100% rename from flutter/lib/src/features/books/domain/entities/author_entity.dart rename to flutter/lib/src/features/authors/domain/entities/author_entity.dart diff --git a/flutter/lib/src/features/authors/domain/repositories/author_repository.dart b/flutter/lib/src/features/authors/domain/repositories/author_repository.dart new file mode 100644 index 0000000..3768ba1 --- /dev/null +++ b/flutter/lib/src/features/authors/domain/repositories/author_repository.dart @@ -0,0 +1,34 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/authors/domain/entities/author_details_entity.dart'; +import 'package:habib_app/src/features/authors/data/datasources/author_datasource.dart'; +import 'package:habib_app/src/features/authors/data/repositories/author_repository_impl.dart'; +import 'package:habib_app/src/features/authors/domain/entities/author_entity.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'author_repository.g.dart'; + +@riverpod +AuthorRepository authorRepository(AuthorRepositoryRef ref) { + return AuthorRepositoryImpl( + authorDatasource: ref.read(authorDatasourceProvider) + ); +} + +abstract interface class AuthorRepository { + + ResultFuture> getAuthors({ required String searchText, required int currentPage }); + + ResultFuture createAuthor({ + required Json authorJson + }); + + ResultFuture getAuthor({ required int authorId }); + + ResultFuture updateAuthor({ + required int authorId, + required Json authorJson + }); + + ResultFuture deleteAuthor({ required int authorId }); +} \ No newline at end of file diff --git a/flutter/lib/src/features/authors/domain/usecases/author_create_author_usecase.dart b/flutter/lib/src/features/authors/domain/usecases/author_create_author_usecase.dart new file mode 100644 index 0000000..2d502da --- /dev/null +++ b/flutter/lib/src/features/authors/domain/usecases/author_create_author_usecase.dart @@ -0,0 +1,43 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/authors/domain/repositories/author_repository.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'author_create_author_usecase.g.dart'; + +@riverpod +AuthorCreateAuthorUsecase authorCreateAuthorUsecase(AuthorCreateAuthorUsecaseRef ref) { + return AuthorCreateAuthorUsecase( + authorRepository: ref.read(authorRepositoryProvider) + ); +} + +class AuthorCreateAuthorUsecase extends UsecaseWithParams { + + final AuthorRepository _authorRepository; + + const AuthorCreateAuthorUsecase({ + required AuthorRepository authorRepository + }) : _authorRepository = authorRepository; + + @override + ResultFuture call(AuthorCreateAuthorUsecaseParams params) async { + return await _authorRepository.createAuthor(authorJson: params.authorJson); + } +} + +class AuthorCreateAuthorUsecaseParams extends Equatable { + + final Json authorJson; + + const AuthorCreateAuthorUsecaseParams({ + required this.authorJson + }); + + @override + List get props => [ + authorJson + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/authors/domain/usecases/author_get_authors_usecase.dart b/flutter/lib/src/features/authors/domain/usecases/author_get_authors_usecase.dart new file mode 100644 index 0000000..5b2e13e --- /dev/null +++ b/flutter/lib/src/features/authors/domain/usecases/author_get_authors_usecase.dart @@ -0,0 +1,50 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/authors/domain/repositories/author_repository.dart'; +import 'package:habib_app/src/features/authors/domain/entities/author_entity.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'author_get_authors_usecase.g.dart'; + +@riverpod +AuthorGetAuthorsUsecase authorGetAuthorsUsecase(AuthorGetAuthorsUsecaseRef ref) { + return AuthorGetAuthorsUsecase( + authorRepository: ref.read(authorRepositoryProvider) + ); +} + +class AuthorGetAuthorsUsecase extends UsecaseWithParams, AuthorGetAuthorsUsecaseParams> { + + final AuthorRepository _authorRepository; + + const AuthorGetAuthorsUsecase({ + required AuthorRepository authorRepository + }) : _authorRepository = authorRepository; + + @override + ResultFuture> call(AuthorGetAuthorsUsecaseParams params) async { + return await _authorRepository.getAuthors( + searchText: params.searchText, + currentPage: params.currentPage + ); + } +} + +class AuthorGetAuthorsUsecaseParams extends Equatable { + + final String searchText; + final int currentPage; + + const AuthorGetAuthorsUsecaseParams({ + required this.searchText, + required this.currentPage + }); + + @override + List get props => [ + searchText, + currentPage + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/authors/presentation/app/author_delete_author_usecase.dart b/flutter/lib/src/features/authors/presentation/app/author_delete_author_usecase.dart new file mode 100644 index 0000000..50d6385 --- /dev/null +++ b/flutter/lib/src/features/authors/presentation/app/author_delete_author_usecase.dart @@ -0,0 +1,41 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/authors/domain/repositories/author_repository.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'author_delete_author_usecase.g.dart'; + +@riverpod +AuthorDeleteAuthorUsecase authorDeleteAuthorUsecase(AuthorDeleteAuthorUsecaseRef ref) { + return AuthorDeleteAuthorUsecase( + authorRepository: ref.read(authorRepositoryProvider) + ); +} + +class AuthorDeleteAuthorUsecase extends UsecaseWithParams { + + final AuthorRepository _authorRepository; + + const AuthorDeleteAuthorUsecase({ + required AuthorRepository authorRepository + }) : _authorRepository = authorRepository; + + @override + ResultFuture call(AuthorDeleteAuthorUsecaseParams params) async { + return await _authorRepository.deleteAuthor(authorId: params.authorId); + } +} + +class AuthorDeleteAuthorUsecaseParams extends Equatable { + + final int authorId; + + const AuthorDeleteAuthorUsecaseParams({ required this.authorId }); + + @override + List get props => [ + authorId + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/authors/presentation/app/author_details_page_notifier.dart b/flutter/lib/src/features/authors/presentation/app/author_details_page_notifier.dart new file mode 100644 index 0000000..8c4fa3f --- /dev/null +++ b/flutter/lib/src/features/authors/presentation/app/author_details_page_notifier.dart @@ -0,0 +1,93 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/authors/domain/entities/author_details_entity.dart'; +import 'package:habib_app/src/features/authors/presentation/app/author_get_author_usecase.dart'; +import 'package:habib_app/core/common/models/error_details.dart'; +import 'package:habib_app/core/utils/result.dart'; + +part 'author_details_page_notifier.g.dart'; + +@riverpod +class AuthorDetailsPageNotifier extends _$AuthorDetailsPageNotifier { + + late AuthorGetAuthorUsecase _authorGetAuthorUsecase; + + @override + AuthorDetailsPageState build(int authorId) { + _authorGetAuthorUsecase = ref.read(authorGetAuthorUsecaseProvider); + return const AuthorDetailsPageState(); + } + + void replace(AuthorDetailsEntity author) { + state = state.copyWith(author: author); + } + + Future fetch() async { + if (state.isLoading) return; + + state = state.copyWith( + isAuthorLoading: true, + removeError: true + ); + + final AuthorGetAuthorUsecaseParams authorParams = AuthorGetAuthorUsecaseParams(authorId: authorId); + final Result result = await _authorGetAuthorUsecase.call(authorParams); + + result.fold( + onSuccess: (AuthorDetailsEntity author) { + state = state.copyWith( + isAuthorLoading: false, + author: author + ); + }, + onFailure: (Object error, StackTrace stackTrace) { + state = state.copyWith( + isAuthorLoading: false, + error: ErrorDetails( + error: error, + stackTrace: stackTrace + ) + ); + } + ); + } +} + +class AuthorDetailsPageState extends Equatable { + + final bool isAuthorLoading; + final ErrorDetails? error; + final AuthorDetailsEntity? author; + + const AuthorDetailsPageState({ + this.isAuthorLoading = false, + this.error, + this.author + }); + + bool get hasError => error != null; + bool get isLoading => isAuthorLoading; + bool get hasAuthor => author != null; + + AuthorDetailsPageState copyWith({ + bool? isAuthorLoading = false, + ErrorDetails? error, + AuthorDetailsEntity? author, + bool removeError = false, + bool removeAuthor = false + }) { + return AuthorDetailsPageState( + isAuthorLoading: isAuthorLoading ?? this.isAuthorLoading, + error: removeError ? null : error ?? this.error, + author: removeAuthor ? null : author ?? this.author + ); + } + + @override + List get props => [ + isAuthorLoading, + error, + author + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/authors/presentation/app/author_get_author_usecase.dart b/flutter/lib/src/features/authors/presentation/app/author_get_author_usecase.dart new file mode 100644 index 0000000..eb95d63 --- /dev/null +++ b/flutter/lib/src/features/authors/presentation/app/author_get_author_usecase.dart @@ -0,0 +1,42 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/authors/domain/entities/author_details_entity.dart'; +import 'package:habib_app/src/features/authors/domain/repositories/author_repository.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'author_get_author_usecase.g.dart'; + +@riverpod +AuthorGetAuthorUsecase authorGetAuthorUsecase(AuthorGetAuthorUsecaseRef ref) { + return AuthorGetAuthorUsecase( + authorRepository: ref.read(authorRepositoryProvider) + ); +} + +class AuthorGetAuthorUsecase extends UsecaseWithParams { + + final AuthorRepository _authorRepository; + + const AuthorGetAuthorUsecase({ + required AuthorRepository authorRepository + }) : _authorRepository = authorRepository; + + @override + ResultFuture call(AuthorGetAuthorUsecaseParams params) async { + return await _authorRepository.getAuthor(authorId: params.authorId); + } +} + +class AuthorGetAuthorUsecaseParams extends Equatable { + + final int authorId; + + const AuthorGetAuthorUsecaseParams({ required this.authorId }); + + @override + List get props => [ + authorId + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/authors/presentation/app/author_update_author_usecase.dart b/flutter/lib/src/features/authors/presentation/app/author_update_author_usecase.dart new file mode 100644 index 0000000..a238f35 --- /dev/null +++ b/flutter/lib/src/features/authors/presentation/app/author_update_author_usecase.dart @@ -0,0 +1,49 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/authors/domain/repositories/author_repository.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'author_update_author_usecase.g.dart'; + +@riverpod +AuthorUpdateAuthorUsecase authorUpdateAuthorUsecase(AuthorUpdateAuthorUsecaseRef ref) { + return AuthorUpdateAuthorUsecase( + authorRepository: ref.read(authorRepositoryProvider) + ); +} + +class AuthorUpdateAuthorUsecase extends UsecaseWithParams { + + final AuthorRepository _authorRepository; + + const AuthorUpdateAuthorUsecase({ + required AuthorRepository authorRepository + }) : _authorRepository = authorRepository; + + @override + ResultFuture call(AuthorUpdateAuthorUsecaseParams params) async { + return await _authorRepository.updateAuthor( + authorId: params.authorId, + authorJson: params.authorJson + ); + } +} + +class AuthorUpdateAuthorUsecaseParams extends Equatable { + + final int authorId; + final Json authorJson; + + const AuthorUpdateAuthorUsecaseParams({ + required this.authorId, + required this.authorJson + }); + + @override + List get props => [ + authorId, + authorJson + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/authors/presentation/app/authors_page_notifier.dart b/flutter/lib/src/features/authors/presentation/app/authors_page_notifier.dart new file mode 100644 index 0000000..39c2b15 --- /dev/null +++ b/flutter/lib/src/features/authors/presentation/app/authors_page_notifier.dart @@ -0,0 +1,112 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/core/common/models/error_details.dart'; +import 'package:habib_app/src/features/authors/domain/entities/author_entity.dart'; +import 'package:habib_app/src/features/authors/domain/usecases/author_get_authors_usecase.dart'; +import 'package:habib_app/core/utils/constants/network_constants.dart'; +import 'package:habib_app/core/utils/result.dart'; + +part 'authors_page_notifier.g.dart'; + +@riverpod +class AuthorsPageNotifier extends _$AuthorsPageNotifier { + + late AuthorGetAuthorsUsecase _authorGetAuthorsUsecase; + + @override + AuthorsPageState build() { + _authorGetAuthorsUsecase = ref.read(authorGetAuthorsUsecaseProvider); + return const AuthorsPageState(); + } + + Future fetchNextPage(String searchText) async { + if (state.isLoading || state.hasReachedEnd) return; + + state = state.copyWith( + isAuthorsLoading: true, + removeError: true + ); + + final AuthorGetAuthorsUsecaseParams params = AuthorGetAuthorsUsecaseParams( + searchText: searchText, + currentPage: state.currentPage + ); + final Result> result = await _authorGetAuthorsUsecase.call(params); + + result.fold( + onSuccess: (List authors) { + state = state.copyWith( + isAuthorsLoading: false, + currentPage: authors.isEmpty + ? state.currentPage + : state.currentPage + 1, + authors: List.of(state.authors)..addAll(authors), + hasReachedEnd: authors.length < NetworkConstants.pageSize + ); + }, + onFailure: (Object error, StackTrace stackTrace) { + state = state.copyWith( + isAuthorsLoading: false, + error: ErrorDetails( + error: error, + stackTrace: stackTrace + ) + ); + } + ); + } + + Future refresh(String searchText) async { + state = const AuthorsPageState(); + await fetchNextPage(searchText); + } +} + +class AuthorsPageState extends Equatable { + + final bool isAuthorsLoading; + final ErrorDetails? error; + final List authors; + final bool hasReachedEnd; + final int currentPage; + + const AuthorsPageState({ + this.isAuthorsLoading = false, + this.error, + this.authors = const [], + this.hasReachedEnd = false, + this.currentPage = 1 + }); + + bool get hasError => error != null; + bool get isLoading => isAuthorsLoading; + bool get hasAuthors => authors.isNotEmpty; + + AuthorsPageState copyWith({ + bool? isAuthorsLoading = false, + ErrorDetails? error, + List? authors, + bool? hasReachedEnd, + int? currentPage, + bool removeError = false, + bool removeAuthors = false + }) { + return AuthorsPageState( + isAuthorsLoading: isAuthorsLoading ?? this.isAuthorsLoading, + error: removeError ? null : error ?? this.error, + authors: removeAuthors ? const [] : authors ?? this.authors, + hasReachedEnd: hasReachedEnd ?? this.hasReachedEnd, + currentPage: currentPage ?? this.currentPage + ); + } + + @override + List get props => [ + isAuthorsLoading, + error, + authors, + hasReachedEnd, + currentPage + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/authors/presentation/pages/author_details_page.dart b/flutter/lib/src/features/authors/presentation/pages/author_details_page.dart new file mode 100644 index 0000000..66f3020 --- /dev/null +++ b/flutter/lib/src/features/authors/presentation/pages/author_details_page.dart @@ -0,0 +1,295 @@ +import 'package:flutter/material.dart'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:habib_app/src/features/authors/presentation/app/author_details_page_notifier.dart'; +import 'package:habib_app/src/features/authors/domain/entities/author_details_entity.dart'; +import 'package:habib_app/src/features/authors/presentation/app/author_delete_author_usecase.dart'; +import 'package:habib_app/src/features/authors/presentation/app/author_update_author_usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/core/utils/validator.dart'; +import 'package:habib_app/core/res/theme/colors/hb_colors.dart'; +import 'package:habib_app/core/res/theme/typography/hb_typography.dart'; +import 'package:habib_app/core/common/widgets/hb_gap.dart'; +import 'package:habib_app/core/common/widgets/hb_text_field.dart'; +import 'package:habib_app/core/extensions/context_extension.dart'; +import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; +import 'package:habib_app/core/common/models/error_details.dart'; +import 'package:habib_app/core/common/widgets/hb_message_box.dart'; +import 'package:habib_app/core/utils/result.dart'; +import 'package:habib_app/core/extensions/object_extension.dart'; +import 'package:habib_app/core/utils/enums/toast_type.dart'; +import 'package:habib_app/core/common/widgets/hb_app_bar.dart'; +import 'package:habib_app/core/common/widgets/hb_scaffold.dart'; +import 'package:habib_app/core/res/hb_icons.dart'; +import 'package:habib_app/core/utils/core_utils.dart'; + +class AuthorDetailsPageParams { + + final int authorId; + + const AuthorDetailsPageParams({ + required this.authorId + }); +} + +class AuthorDetailsPage extends StatefulHookConsumerWidget { + + final AuthorDetailsPageParams params; + + const AuthorDetailsPage({ + super.key, + required this.params + }); + + @override + ConsumerState createState() => _AuthorDetailsPageState(); +} + +class _AuthorDetailsPageState extends ConsumerState { + + late TextEditingController _titleController; + late TextEditingController _firstNameController; + late TextEditingController _lastNameController; + + void _onDetailsStateUpdate(AuthorDetailsPageState? _, AuthorDetailsPageState next) { + if (next.hasError) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: next.error!.errorTitle, + description: next.error!.errorDescription, + ); + } + } + + Future _onRefresh() async { + ref.read(authorDetailsPageNotifierProvider(widget.params.authorId).notifier).fetch(); + } + + Future _onSave() async { + final AuthorDetailsPageState pageState = ref.watch(authorDetailsPageNotifierProvider(widget.params.authorId)); + final AuthorDetailsEntity author = pageState.author!; + + final String title = _titleController.text.trim(); + final String firstName = _firstNameController.text.trim(); + final String lastName = _lastNameController.text.trim(); + + try { + Validator.validateAuthorUpdate( + title: title, + firstName: firstName, + lastName: lastName + ); + } catch (e) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: e.errorTitle, + description: e.errorDescription, + ); + return; + } + + final bool replaceTitle = title != (author.title ?? ''); + final bool replaceFirstName = firstName != author.firstName; + final bool replaceLastName = lastName != author.lastName; + + Json authorJson = { + if (replaceTitle) 'title' : "'$title'", + if (replaceFirstName) 'first_name' : "'$firstName'", + if (replaceLastName) 'last_name' : "'$lastName'" + }; + + if (authorJson.isEmpty) return; + + final AuthorUpdateAuthorUsecase authorUpdateAuthorUsecase = ref.read(authorUpdateAuthorUsecaseProvider); + final AuthorUpdateAuthorUsecaseParams authorUpdateAuthorUsecaseParams = AuthorUpdateAuthorUsecaseParams( + authorId: author.id, + authorJson: authorJson + ); + final Result authorUpdateAuthorUsecaseResult = await authorUpdateAuthorUsecase.call(authorUpdateAuthorUsecaseParams); + + authorUpdateAuthorUsecaseResult.fold( + onSuccess: (void _) { + ref.read(authorDetailsPageNotifierProvider(widget.params.authorId).notifier).replace( + AuthorDetailsEntity( + id: author.id, + title: replaceTitle ? title : author.title, + firstName: replaceFirstName ? firstName : author.firstName, + lastName: replaceLastName ? lastName : author.lastName + ) + ); + + CoreUtils.showToast( + context, + type: ToastType.success, + title: 'Erfolgreich aktualisiert.', + description: 'Der Autor wurde erfolgreich aktualisiert.' + ); + }, + onFailure: (Object error, StackTrace stackTrace) { + final ErrorDetails errorDetails = ErrorDetails( + error: error, + stackTrace: stackTrace + ); + CoreUtils.showToast( + context, + type: ToastType.error, + title: errorDetails.errorTitle, + description: errorDetails.errorDescription, + ); + } + ); + } + + Future _onDelete() async { + final bool? success = await showHBMessageBox( + context, + 'Diesen Autor wirklich löschen?', + 'Wenn Sie diesen Autor löschen, werden alle damit verbundenen Daten ebenfalls gelöscht. Dieser Vorgang kann nicht rückgängig gemacht werden.', + 'Löschen', + onPressed: () async { + final AuthorDeleteAuthorUsecase authorDeleteAuthorUsecase = ref.read(authorDeleteAuthorUsecaseProvider); + final AuthorDeleteAuthorUsecaseParams authorDeleteAuthorUsecaseParams = AuthorDeleteAuthorUsecaseParams(authorId: widget.params.authorId); + final Result authorDeleteAuthorUsecaseResult = await authorDeleteAuthorUsecase.call(authorDeleteAuthorUsecaseParams); + + return authorDeleteAuthorUsecaseResult.fold( + onSuccess: (void _) => true, + onFailure: (Object error, StackTrace stackTrace) { + final ErrorDetails errorDetails = ErrorDetails( + error: error, + stackTrace: stackTrace + ); + CoreUtils.showToast( + context, + type: ToastType.error, + title: errorDetails.errorTitle, + description: errorDetails.errorDescription, + ); + return false; + } + ); + } + ); + + if (mounted && context.canPop() && (success ?? false)) context.pop(true); + } + + @override + void initState() { + super.initState(); + + CoreUtils.postFrameCall(() { + ref.read(authorDetailsPageNotifierProvider(widget.params.authorId).notifier).fetch(); + }); + } + + @override + Widget build(BuildContext context) { + + final AuthorDetailsPageState pageState = ref.watch(authorDetailsPageNotifierProvider(widget.params.authorId)); + + if (pageState.hasAuthor) { + _titleController = useTextEditingController(text: pageState.author!.title); + _firstNameController = useTextEditingController(text: pageState.author!.firstName); + _lastNameController = useTextEditingController(text: pageState.author!.lastName); + } + + ref.listen( + authorDetailsPageNotifierProvider(widget.params.authorId), + _onDetailsStateUpdate + ); + + return HBScaffold( + appBar: HBAppBar( + context: context, + title: 'Details', + backButton: const HBAppBarBackButton(), + actionButtons: [ + HBAppBarButton( + onPressed: _onRefresh, + icon: HBIcons.arrowPath, + isEnabled: !pageState.isLoading + ), + HBAppBarButton( + onPressed: _onSave, + icon: HBIcons.cloudArrowUp, + isEnabled: pageState.hasAuthor, + ), + HBAppBarButton( + onPressed: _onDelete, + icon: HBIcons.trash, + isEnabled: pageState.hasAuthor + ) + ] + ), + body: pageState.hasAuthor + ? SingleChildScrollView( + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + top: HBSpacing.lg + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Personendetails', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 20.0, + fontWeight: FontWeight.w600, + color: HBColors.gray900 + ) + ), + const HBGap.xl(), + Row( + children: [ + Expanded( + child: HBTextField( + title: 'Titel', + controller: _titleController, + icon: HBIcons.home + ) + ), + const HBGap.xl(), + Expanded( + child: HBTextField( + title: 'Vorname', + controller: _firstNameController, + icon: HBIcons.home + ) + ), + const HBGap.xl(), + Expanded( + child: HBTextField( + title: 'Nachname', + controller: _lastNameController, + icon: HBIcons.home + ) + ), + ] + ) + ] + ) + ) + : pageState.hasError + ? Center( + child: Text( + pageState.error!.errorDescription, + textAlign: TextAlign.center, + style: HBTypography.base.copyWith( + fontSize: 14.0, + fontWeight: FontWeight.w600, + color: HBColors.gray900 + ), + ) + ) + : const SizedBox.shrink() + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/authors/presentation/pages/authors_page.dart b/flutter/lib/src/features/authors/presentation/pages/authors_page.dart new file mode 100644 index 0000000..55f665d --- /dev/null +++ b/flutter/lib/src/features/authors/presentation/pages/authors_page.dart @@ -0,0 +1,192 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:habib_app/core/extensions/object_extension.dart'; +import 'package:habib_app/src/features/authors/presentation/app/authors_page_notifier.dart'; +import 'package:habib_app/src/features/authors/domain/entities/author_entity.dart'; +import 'package:habib_app/core/common/widgets/hb_table.dart'; +import 'package:habib_app/core/services/routes.dart'; +import 'package:habib_app/core/common/widgets/hb_app_bar.dart'; +import 'package:habib_app/core/common/widgets/hb_button.dart'; +import 'package:habib_app/core/common/widgets/hb_gap.dart'; +import 'package:habib_app/core/common/widgets/hb_scaffold.dart'; +import 'package:habib_app/core/common/widgets/hb_text_field.dart'; +import 'package:habib_app/core/extensions/context_extension.dart'; +import 'package:habib_app/core/res/hb_icons.dart'; +import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; +import 'package:habib_app/core/utils/constants/hb_ui_constants.dart'; +import 'package:habib_app/core/utils/core_utils.dart'; +import 'package:habib_app/core/utils/enums/toast_type.dart'; + +class AuthorsPage extends StatefulHookConsumerWidget { + + const AuthorsPage({ super.key }); + + @override + ConsumerState createState() => _AuthorsPageState(); +} + +class _AuthorsPageState extends ConsumerState { + + final ScrollController _scrollController = ScrollController(); + + late TextEditingController _searchController; + + void _onPageStateUpdate(AuthorsPageState? _, AuthorsPageState next) { + if (next.hasError) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: next.error!.errorTitle, + description: next.error!.errorDescription, + ); + } + } + + bool get _isBottom { + if (!_scrollController.hasClients) return false; + final double maxScroll = _scrollController.position.maxScrollExtent; + final double currentScroll = _scrollController.offset; + return currentScroll >= maxScroll * 0.9; + } + + void _onScroll() { + if (_isBottom) { + ref.read(authorsPageNotifierProvider.notifier).fetchNextPage(_searchText); + } + } + + HBTableStatus get _tableStatus { + final AuthorsPageState pageState = ref.read(authorsPageNotifierProvider); + if (pageState.hasAuthors) return HBTableStatus.data; + if (pageState.hasError || !pageState.hasAuthors) return HBTableStatus.text; + return HBTableStatus.loading; + } + + String? get _tableText { + final AuthorsPageState pageState = ref.read(authorsPageNotifierProvider); + if (!pageState.isLoading && !pageState.hasError && !pageState.hasAuthors) return 'Keine Autoren gefunden.'; + if (pageState.hasError) return 'Ein Fehler ist aufgetreten.'; + return null; + } + + String get _searchText { + return _searchController.text.trim(); + } + + Future _onAuthorPressed(int authorId) async { + await AuthorDetailsRoute(authorId: authorId).push(context); + } + + Future _onCreateAuthor() async { + await const CreateAuthorRoute().push(context); + } + + Future _onSearchChanged(String _) async { + await ref.read(authorsPageNotifierProvider.notifier).refresh(_searchText); + } + + Future _onRefresh() async { + await ref.read(authorsPageNotifierProvider.notifier).refresh(_searchText); + } + + @override + void initState() { + super.initState(); + + _scrollController.addListener(_onScroll); + + CoreUtils.postFrameCall(() { + ref.read(authorsPageNotifierProvider.notifier).fetchNextPage(_searchText); + }); + } + + @override + void dispose() { + _scrollController + ..removeListener(_onScroll) + ..dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + + final AuthorsPageState pageState = ref.watch(authorsPageNotifierProvider); + + _searchController = useTextEditingController(); + + ref.listen( + authorsPageNotifierProvider, + _onPageStateUpdate + ); + + return HBScaffold( + appBar: HBAppBar( + context: context, + title: 'Autoren' + ), + body: Column( + children: [ + Padding( + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + top: HBSpacing.lg + ), + child: Row( + children: [ + HBTextField( + controller: _searchController, + onChanged: _onSearchChanged, + icon: HBIcons.magnifyingGlass, + hint: 'Name', + maxWidth: 500.0 + ), + const HBGap.lg(), + const Spacer(), + HBButton.shrinkFill( + onPressed: _onCreateAuthor, + icon: HBIcons.plus, + title: 'Neue/r Autor*in' + ), + const HBGap.md(), + HBButton.shrinkFill( + onPressed: _onRefresh, + icon: HBIcons.arrowPath + ) + ] + ) + ), + Expanded( + child: HBTable( + onPressed: (int index) => _onAuthorPressed(pageState.authors[index].id), + status: _tableStatus, + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + bottom: context.bottomPadding + HBSpacing.lg, + top: HBSpacing.xxl + ), + controller: _scrollController, + tableWidth: context.width - HBUIConstants.navigationRailWidth - context.rightPadding - 4.0 * HBSpacing.lg, + columnLength: 1, + fractions: const [ 1.0 ], + titles: const [ 'Name' ], + items: List.generate(pageState.authors.length, (int index) { + final AuthorEntity author = pageState.authors[index]; + return [ + HBTableText(text: '${ author.title != null ? '${ author.title } ' : '' }${ author.firstName } ${ author.lastName }') + ]; + }), + text: _tableText + ) + ) + ] + ) + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/authors/presentation/pages/create_author_page.dart b/flutter/lib/src/features/authors/presentation/pages/create_author_page.dart new file mode 100644 index 0000000..4841b1b --- /dev/null +++ b/flutter/lib/src/features/authors/presentation/pages/create_author_page.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:habib_app/src/features/authors/domain/usecases/author_create_author_usecase.dart'; +import 'package:habib_app/core/common/models/error_details.dart'; +import 'package:habib_app/core/common/widgets/hb_dialog.dart'; +import 'package:habib_app/core/common/widgets/hb_gap.dart'; +import 'package:habib_app/core/common/widgets/hb_text_field.dart'; +import 'package:habib_app/core/extensions/object_extension.dart'; +import 'package:habib_app/core/res/hb_icons.dart'; +import 'package:habib_app/core/services/routes.dart'; +import 'package:habib_app/core/utils/core_utils.dart'; +import 'package:habib_app/core/utils/enums/toast_type.dart'; +import 'package:habib_app/core/utils/result.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/core/utils/validator.dart'; + +class CreateAuthorPage extends StatefulHookConsumerWidget { + + const CreateAuthorPage({ super.key }); + + @override + ConsumerState createState() => _CreateAuthorPageState(); +} + +class _CreateAuthorPageState extends ConsumerState { + + late TextEditingController _firstNameController; + late TextEditingController _lastNameController; + late TextEditingController _titleController; + + Future _handleCreate() async { + final String title = _titleController.text.trim(); + final String firstName = _firstNameController.text.trim(); + final String lastName = _lastNameController.text.trim(); + + try { + Validator.validateAuthorCreate( + title: title, + firstName: firstName, + lastName: lastName + ); + } catch (e) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: e.errorTitle, + description: e.errorDescription, + ); + return; + } + + Json authorJson = { + if (title.isNotEmpty) 'title' : "'$title'", + 'first_name' : "'$firstName'", + 'last_name' : "'$lastName'" + }; + + final AuthorCreateAuthorUsecase authorCreateAuthorUsecase = ref.read(authorCreateAuthorUsecaseProvider); + final AuthorCreateAuthorUsecaseParams authorCreateAuthorUsecaseParams = AuthorCreateAuthorUsecaseParams(authorJson: authorJson); + final Result authorCreateAuthorUsecaseResult = await authorCreateAuthorUsecase.call(authorCreateAuthorUsecaseParams); + + authorCreateAuthorUsecaseResult.fold( + onSuccess: (int authorId) async { + CoreUtils.showToast( + context, + type: ToastType.success, + title: 'Erfolgreich angelegt.', + description: 'Der Autor wurde erfolgreich angelegt.' + ); + + if (!mounted) return; + + context.pop(); + + await AuthorDetailsRoute(authorId: authorId).push(context); + }, + onFailure: (Object error, StackTrace stackTrace) { + final ErrorDetails errorDetails = ErrorDetails( + error: error, + stackTrace: stackTrace + ); + CoreUtils.showToast( + context, + type: ToastType.error, + title: errorDetails.errorTitle, + description: errorDetails.errorDescription, + ); + } + ); + } + + @override + Widget build(BuildContext context) { + + _firstNameController = useTextEditingController(); + _lastNameController = useTextEditingController(); + _titleController = useTextEditingController(); + + return HBDialog( + title: 'Neuer Autor', + actionButton: HBDialogActionButton( + onPressed: _handleCreate, + title: 'Erstellen' + ), + children: [ + const HBDialogSection( + title: 'Personendetails', + isFirstSection: true + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Expanded( + child: HBTextField( + controller: _titleController, + icon: HBIcons.academicCap, + title: 'Titel' + ) + ), + const HBGap.lg(), + Expanded( + child: HBTextField( + controller: _firstNameController, + icon: HBIcons.user, + title: 'Vorname' + ) + ) + ] + ), + const HBGap.lg(), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Expanded( + child: HBTextField( + controller: _lastNameController, + icon: HBIcons.user, + title: 'Nachname' + ) + ), + const HBGap.lg(), + const Spacer() + ] + ) + ] + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/authors/presentation/widgets/authors_selection_dialog.dart b/flutter/lib/src/features/authors/presentation/widgets/authors_selection_dialog.dart new file mode 100644 index 0000000..340739c --- /dev/null +++ b/flutter/lib/src/features/authors/presentation/widgets/authors_selection_dialog.dart @@ -0,0 +1,241 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:habib_app/src/features/authors/domain/entities/author_entity.dart'; +import 'package:habib_app/src/features/authors/presentation/app/authors_page_notifier.dart'; +import 'package:habib_app/core/common/widgets/hb_button.dart'; +import 'package:habib_app/core/common/widgets/hb_gap.dart'; +import 'package:habib_app/core/common/widgets/hb_selection_dialog.dart'; +import 'package:habib_app/core/common/widgets/hb_table.dart'; +import 'package:habib_app/core/common/widgets/hb_text_field.dart'; +import 'package:habib_app/core/extensions/context_extension.dart'; +import 'package:habib_app/core/extensions/object_extension.dart'; +import 'package:habib_app/core/res/hb_icons.dart'; +import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; +import 'package:habib_app/core/services/routes.dart'; +import 'package:habib_app/core/utils/core_utils.dart'; +import 'package:habib_app/core/utils/enums/toast_type.dart'; +import 'package:habib_app/src/features/books/presentation/pages/create_book_page.dart'; + +Future?> showAuthorsSelectionDialog({ + required BuildContext context, + required List authors +}) async { + return await showHBSelectionDialog>( + context: context, + title: 'Autoren wählen', + content: AuthorsSelectionDialog(authors: authors) + ); +} + +class AuthorsSelectionDialog extends StatefulHookConsumerWidget { + + final List authors; + + const AuthorsSelectionDialog({ + super.key, + required this.authors + }); + + @override + ConsumerState createState() => _AuthorsSelectionDialogState(); +} + +class _AuthorsSelectionDialogState extends ConsumerState { + + late ValueNotifier> _authorsNotifier; + + final ScrollController _scrollController = ScrollController(); + + late TextEditingController _searchController; + + void _onPageStateUpdate(AuthorsPageState? _, AuthorsPageState next) { + if (next.hasError) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: next.error!.errorTitle, + description: next.error!.errorDescription, + ); + } + } + + bool get _isBottom { + if (!_scrollController.hasClients) return false; + final double maxScroll = _scrollController.position.maxScrollExtent; + final double currentScroll = _scrollController.offset; + return currentScroll >= maxScroll * 0.9; + } + + void _onScroll() { + if (_isBottom) { + ref.read(authorsPageNotifierProvider.notifier).fetchNextPage(_searchText); + } + } + + HBTableStatus get _tableStatus { + final AuthorsPageState pageState = ref.read(authorsPageNotifierProvider); + if (pageState.hasAuthors) return HBTableStatus.data; + if (pageState.hasError || !pageState.hasAuthors) return HBTableStatus.text; + return HBTableStatus.loading; + } + + String? get _tableText { + final AuthorsPageState pageState = ref.read(authorsPageNotifierProvider); + if (!pageState.isLoading && !pageState.hasError && !pageState.hasAuthors) return 'Keine Autoren gefunden.'; + if (pageState.hasError) return 'Ein Fehler ist aufgetreten.'; + return null; + } + + String get _searchText { + return _searchController.text.trim(); + } + + Future _onAuthorPressed(int authorId) async { + await AuthorDetailsRoute(authorId: authorId).push(context); + } + + Future _onSearchChanged(String _) async { + await ref.read(authorsPageNotifierProvider.notifier).refresh(_searchText); + } + + Future _onRefresh() async { + await ref.read(authorsPageNotifierProvider.notifier).refresh(_searchText); + } + + void _onRowPressed(AuthorEntity selectedAuthor) async { + if (_authorsNotifier.value.map((CreateBookAuthor author) => author.id).contains(selectedAuthor.id)) { + _authorsNotifier.value = _authorsNotifier.value.where((CreateBookAuthor author) => author.id != selectedAuthor.id).toList(); + } else { + final CreateBookAuthor newAuthor = CreateBookAuthor( + id: selectedAuthor.id, + title: selectedAuthor.title, + firstName: selectedAuthor.firstName, + lastName: selectedAuthor.lastName + ); + _authorsNotifier.value = [ ..._authorsNotifier.value, newAuthor ]; + } + } + + void _cancel() { + context.pop(); + } + + void _onChoose() { + context.pop>(_authorsNotifier.value); + } + + @override + void initState() { + super.initState(); + + _scrollController.addListener(_onScroll); + + CoreUtils.postFrameCall(() { + ref.read(authorsPageNotifierProvider.notifier).fetchNextPage(_searchText); + }); + } + + @override + void dispose() { + _scrollController + ..removeListener(_onScroll) + ..dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + + _authorsNotifier = useState>(widget.authors); + + final AuthorsPageState pageState = ref.watch(authorsPageNotifierProvider); + + _searchController = useTextEditingController(); + + ref.listen( + authorsPageNotifierProvider, + _onPageStateUpdate + ); + + return Column( + children: [ + Padding( + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + top: HBSpacing.lg + ), + child: Row( + children: [ + Expanded( + child: HBTextField( + controller: _searchController, + onChanged: _onSearchChanged, + icon: HBIcons.magnifyingGlass, + hint: 'Name' + ) + ), + const HBGap.lg(), + HBButton.shrinkFill( + onPressed: _onRefresh, + icon: HBIcons.arrowPath + ) + ] + ) + ), + Expanded( + child: HBTable( + onPressed: (int index) => _onRowPressed(pageState.authors[index]), + status: _tableStatus, + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + bottom: context.bottomPadding + HBSpacing.lg, + top: HBSpacing.xxl + ), + controller: _scrollController, + tableWidth: 800.0 - 4.0 * HBSpacing.lg, + columnLength: 2, + fractions: const [ 0.95, 0.05 ], + titles: const [ 'Name', '' ], + items: List.generate(pageState.authors.length, (int index) { + final AuthorEntity author = pageState.authors[index]; + final bool isSelected = _authorsNotifier.value.map((CreateBookAuthor author) => author.id).contains(author.id); + return [ + HBTableText( + onPressed: () => _onAuthorPressed(author.id), + text: '${ author.title != null ? '${ author.title } ' : '' }${ author.firstName } ${ author.lastName }' + ), + HBTableRadioIndicator(isSelected: isSelected) + ]; + }), + text: _tableText + ) + ), + const HBGap.lg(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: HBSpacing.lg), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + HBButton.shrinkFill( + onPressed: _onChoose, + title: 'Wählen' + ), + const HBGap.md(), + HBButton.shrinkOutline( + onPressed: _cancel, + title: 'Abbrechen' + ) + ] + ) + ) + ] + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/books/data/datasources/book_datasource.dart b/flutter/lib/src/features/books/data/datasources/book_datasource.dart index 346bbee..bd54a35 100644 --- a/flutter/lib/src/features/books/data/datasources/book_datasource.dart +++ b/flutter/lib/src/features/books/data/datasources/book_datasource.dart @@ -1,5 +1,8 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:habib_app/src/features/books/data/dto/book_details_dto.dart'; +import 'package:habib_app/src/features/books/data/dto/book_borrow_dto.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; import 'package:habib_app/core/services/database.dart'; import 'package:habib_app/src/features/books/data/datasources/book_datasource_impl.dart'; import 'package:habib_app/src/features/books/data/dto/book_dto.dart'; @@ -18,4 +21,28 @@ abstract interface class BookDatasource { const BookDatasource(); Future> getBooks({ required String searchText, required int currentPage }); + + Future> getBookBorrows({ required int bookId, required String searchText, required int currentPage }); + + Future getBook({ required int bookId }); + + Future createBook({ + required Json bookJson, + required List authorIds, + required List categoryIds, + required List publisherIds + }); + + Future updateBook({ + required int bookId, + required Json bookJson, + required List removeAuthorIds, + required List addAuthorIds, + required List removeCategoryIds, + required List addCategoryIds, + required List removePublisherIds, + required List addPublisherIds + }); + + Future deleteBook({ required int bookId }); } \ No newline at end of file diff --git a/flutter/lib/src/features/books/data/datasources/book_datasource_impl.dart b/flutter/lib/src/features/books/data/datasources/book_datasource_impl.dart index aa3becb..31f7253 100644 --- a/flutter/lib/src/features/books/data/datasources/book_datasource_impl.dart +++ b/flutter/lib/src/features/books/data/datasources/book_datasource_impl.dart @@ -1,6 +1,8 @@ import 'package:habib_app/core/utils/typedefs.dart'; import 'package:habib_app/core/services/database.dart'; import 'package:habib_app/src/features/books/data/datasources/book_datasource.dart'; +import 'package:habib_app/src/features/books/data/dto/book_borrow_dto.dart'; +import 'package:habib_app/src/features/books/data/dto/book_details_dto.dart'; import 'package:habib_app/src/features/books/data/dto/book_dto.dart'; class BookDatasourceImpl implements BookDatasource { @@ -19,4 +21,63 @@ class BookDatasourceImpl implements BookDatasource { ); return BookDto.listFromJsonList(jsonList); } + + @override + Future> getBookBorrows({ required int bookId, required String searchText, required int currentPage }) async { + final List jsonList = await _database.getBookBorrows( + bookId: bookId, + searchText: searchText, + currentPage: currentPage + ); + return BookBorrowDto.listFromJsonList(jsonList); + } + + @override + Future getBook({ required int bookId }) async { + final Json json = await _database.getBook(bookId: bookId); + return BookDetailsDto.fromJson(json); + } + + @override + Future createBook({ + required Json bookJson, + required List authorIds, + required List categoryIds, + required List publisherIds + }) async { + return await _database.createBook( + bookJson: bookJson, + authorIds: authorIds, + categoryIds: categoryIds, + publisherIds: publisherIds + ); + } + + @override + Future updateBook({ + required int bookId, + required Json bookJson, + required List removeAuthorIds, + required List addAuthorIds, + required List removeCategoryIds, + required List addCategoryIds, + required List removePublisherIds, + required List addPublisherIds + }) async { + return await _database.updateBook( + bookId: bookId, + bookJson: bookJson, + removeAuthorIds: removeAuthorIds, + addAuthorIds: addAuthorIds, + removeCategoryIds: removeCategoryIds, + addCategoryIds: addCategoryIds, + removePublisherIds: removePublisherIds, + addPublisherIds: addPublisherIds + ); + } + + @override + Future deleteBook({ required int bookId }) async { + return await _database.deleteBook(bookId); + } } \ No newline at end of file diff --git a/flutter/lib/src/features/books/data/dto/book_author_dto.dart b/flutter/lib/src/features/books/data/dto/book_author_dto.dart index 150855e..b9ba742 100644 --- a/flutter/lib/src/features/books/data/dto/book_author_dto.dart +++ b/flutter/lib/src/features/books/data/dto/book_author_dto.dart @@ -1,7 +1,7 @@ import 'package:habib_app/core/utils/typedefs.dart'; -import 'package:habib_app/src/features/books/domain/entities/author_entity.dart'; +import 'package:habib_app/src/features/books/domain/entities/book_author_entity.dart'; -class BookAuthorDto extends AuthorEntity { +class BookAuthorDto extends BookAuthorEntity { const BookAuthorDto({ required super.id, diff --git a/flutter/lib/src/features/books/data/dto/book_borrow_customer_dto.dart b/flutter/lib/src/features/books/data/dto/book_borrow_customer_dto.dart new file mode 100644 index 0000000..cc8491e --- /dev/null +++ b/flutter/lib/src/features/books/data/dto/book_borrow_customer_dto.dart @@ -0,0 +1,21 @@ +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/books/domain/entities/book_borrow_customer_entity.dart'; + +class BookBorrowCustomerDto extends BookBorrowCustomerEntity { + + const BookBorrowCustomerDto({ + required super.id, + super.title, + required super.firstName, + required super.lastName + }); + + factory BookBorrowCustomerDto.fromJson(Json customerJson) { + return BookBorrowCustomerDto( + id: customerJson['customer_id'] as int, + title: customerJson['customer_title'] as String?, + firstName: customerJson['customer_first_name'] as String, + lastName: customerJson['customer_last_name'] as String + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/books/data/dto/book_borrow_dto.dart b/flutter/lib/src/features/books/data/dto/book_borrow_dto.dart new file mode 100644 index 0000000..2f79fe8 --- /dev/null +++ b/flutter/lib/src/features/books/data/dto/book_borrow_dto.dart @@ -0,0 +1,29 @@ +import 'dart:convert'; + +import 'package:habib_app/core/utils/enums/borrow_status.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/books/data/dto/book_borrow_customer_dto.dart'; +import 'package:habib_app/src/features/books/domain/entities/book_borrow_entity.dart'; + +class BookBorrowDto extends BookBorrowEntity { + + const BookBorrowDto({ + required super.id, + required super.customer, + required super.endDate, + required super.status + }); + + factory BookBorrowDto.fromJson(Json borrowJson) { + return BookBorrowDto( + id: borrowJson['borrow_id'] as int, + customer: BookBorrowCustomerDto.fromJson(json.decode(borrowJson['customer'] as String)), + endDate: borrowJson['borrow_end_date'] as DateTime, + status: BorrowStatus.fromDatabaseValue(borrowJson['borrow_status'] as String) + ); + } + + static List listFromJsonList(List jsonList) { + return jsonList.map((Json json) => BookBorrowDto.fromJson(json)).toList(); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/books/data/dto/book_category_dto.dart b/flutter/lib/src/features/books/data/dto/book_category_dto.dart index f9ef8ac..8cce56d 100644 --- a/flutter/lib/src/features/books/data/dto/book_category_dto.dart +++ b/flutter/lib/src/features/books/data/dto/book_category_dto.dart @@ -1,7 +1,7 @@ import 'package:habib_app/core/utils/typedefs.dart'; -import 'package:habib_app/src/features/books/domain/entities/category_entity.dart'; +import 'package:habib_app/src/features/books/domain/entities/book_category_entity.dart'; -class BookCategoryDto extends CategoryEntity { +class BookCategoryDto extends BookCategoryEntity { const BookCategoryDto({ required super.id, diff --git a/flutter/lib/src/features/books/data/dto/book_details_author_dto.dart b/flutter/lib/src/features/books/data/dto/book_details_author_dto.dart new file mode 100644 index 0000000..30b343a --- /dev/null +++ b/flutter/lib/src/features/books/data/dto/book_details_author_dto.dart @@ -0,0 +1,21 @@ +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/books/domain/entities/book_details_author_entity.dart'; + +class BookDetailsAuthorDto extends BookDetailsAuthorEntity { + + const BookDetailsAuthorDto({ + required super.id, + super.title, + required super.firstName, + required super.lastName + }); + + factory BookDetailsAuthorDto.fromJson(Json authorJson) { + return BookDetailsAuthorDto( + id: authorJson['author_id'] as int, + title: authorJson['author_title'] as String?, + firstName: authorJson['author_first_name'] as String, + lastName: authorJson['author_last_name'] as String + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/books/data/dto/book_details_category_dto.dart b/flutter/lib/src/features/books/data/dto/book_details_category_dto.dart new file mode 100644 index 0000000..f5ee4a5 --- /dev/null +++ b/flutter/lib/src/features/books/data/dto/book_details_category_dto.dart @@ -0,0 +1,17 @@ +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/books/domain/entities/book_details_category_entity.dart'; + +class BookDetailsCategoryDto extends BookDetailsCategoryEntity { + + const BookDetailsCategoryDto({ + required super.id, + required super.name + }); + + factory BookDetailsCategoryDto.fromJson(Json categoryJson) { + return BookDetailsCategoryDto( + id: categoryJson['category_id'] as int, + name: categoryJson['category_name'] as String + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/books/data/dto/book_details_dto.dart b/flutter/lib/src/features/books/data/dto/book_details_dto.dart new file mode 100644 index 0000000..ebcaec2 --- /dev/null +++ b/flutter/lib/src/features/books/data/dto/book_details_dto.dart @@ -0,0 +1,42 @@ +import 'dart:convert'; + +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/books/data/dto/book_details_author_dto.dart'; +import 'package:habib_app/src/features/books/data/dto/book_details_category_dto.dart'; +import 'package:habib_app/src/features/books/data/dto/book_details_publisher_dto.dart'; +import 'package:habib_app/src/features/books/domain/entities/book_details_entity.dart'; + +class BookDetailsDto extends BookDetailsEntity { + + const BookDetailsDto({ + required super.id, + required super.title, + super.isbn10, + super.isbn13, + super.edition, + super.publishDate, + super.bought, + required super.receivedAt, + required super.authors, + required super.categories, + required super.publishers + }); + + factory BookDetailsDto.fromJson(Json bookJson) { + return BookDetailsDto( + id: bookJson['book_id'] as int, + title: bookJson['book_title'] as String, + isbn10: bookJson['book_isbn_10'] as String?, + isbn13: bookJson['book_isbn_13'] as String?, + edition: bookJson['book_edition'] as int?, + publishDate: bookJson['book_publish_date'] as DateTime?, + bought: (bookJson['book_bought'] as int?) == 1, + receivedAt: bookJson['book_received_at'] as DateTime, + authors: List.from(json.decode(bookJson['authors'] as String)).map((Json authorJson) => BookDetailsAuthorDto.fromJson(authorJson)).toList(), + categories: List.from(json.decode(bookJson['categories'] as String)).map((Json categoryJson) => BookDetailsCategoryDto.fromJson(categoryJson)).toList(), + publishers: List.from(json.decode(bookJson['publishers'] as String)).map((Json publisherJson) => BookDetailsPublisherDto.fromJson(publisherJson)).toList() + ); + } +} + +// TODO: From json wrft keinen sichtbaren fehler \ No newline at end of file diff --git a/flutter/lib/src/features/books/data/dto/book_details_publisher_dto.dart b/flutter/lib/src/features/books/data/dto/book_details_publisher_dto.dart new file mode 100644 index 0000000..0008820 --- /dev/null +++ b/flutter/lib/src/features/books/data/dto/book_details_publisher_dto.dart @@ -0,0 +1,19 @@ +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/books/domain/entities/book_details_publisher_entity.dart'; + +class BookDetailsPublisherDto extends BookDetailsPublisherEntity { + + const BookDetailsPublisherDto({ + required super.id, + required super.name, + super.city + }); + + factory BookDetailsPublisherDto.fromJson(Json publisherJson) { + return BookDetailsPublisherDto( + id: publisherJson['publisher_id'] as int, + name: publisherJson['publisher_name'] as String, + city: publisherJson['publisher_city'] as String? + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/books/data/repositories/book_repository_impl.dart b/flutter/lib/src/features/books/data/repositories/book_repository_impl.dart index 18ea2d7..86543ee 100644 --- a/flutter/lib/src/features/books/data/repositories/book_repository_impl.dart +++ b/flutter/lib/src/features/books/data/repositories/book_repository_impl.dart @@ -1,7 +1,11 @@ import 'package:habib_app/core/utils/result.dart'; import 'package:habib_app/core/utils/typedefs.dart'; import 'package:habib_app/src/features/books/data/datasources/book_datasource.dart'; +import 'package:habib_app/src/features/books/data/dto/book_borrow_dto.dart'; +import 'package:habib_app/src/features/books/data/dto/book_details_dto.dart'; import 'package:habib_app/src/features/books/data/dto/book_dto.dart'; +import 'package:habib_app/src/features/books/domain/entities/book_borrow_entity.dart'; +import 'package:habib_app/src/features/books/domain/entities/book_details_entity.dart'; import 'package:habib_app/src/features/books/domain/entities/book_entity.dart'; import 'package:habib_app/src/features/books/domain/repositories/book_repository.dart'; @@ -21,7 +25,89 @@ class BookRepositoryImpl implements BookRepository { currentPage: currentPage ); return Success(result); - } on Exception catch (e) { + } catch (e) { + return Failure(e); + } + } + + @override + ResultFuture> getBookBorrows({ required int bookId, required String searchText, required int currentPage }) async { + try { + final List result = await _bookDatasource.getBookBorrows( + bookId: bookId, + searchText: searchText, + currentPage: currentPage + ); + return Success(result); + } catch (e) { + return Failure(e); + } + } + + @override + ResultFuture getBook({ required int bookId }) async { + try { + final BookDetailsDto result = await _bookDatasource.getBook(bookId: bookId); + return Success(result); + } catch (e) { + return Failure(e); + } + } + + @override + ResultFuture createBook({ + required Json bookJson, + required List authorIds, + required List categoryIds, + required List publisherIds + }) async { + try { + final int bookId = await _bookDatasource.createBook( + bookJson: bookJson, + authorIds: authorIds, + categoryIds: categoryIds, + publisherIds: publisherIds + ); + return Success(bookId); + } catch (e) { + return Failure(e); + } + } + + @override + ResultFuture updateBook({ + required int bookId, + required Json bookJson, + required List removeAuthorIds, + required List addAuthorIds, + required List removeCategoryIds, + required List addCategoryIds, + required List removePublisherIds, + required List addPublisherIds + }) async { + try { + await _bookDatasource.updateBook( + bookId: bookId, + bookJson: bookJson, + removeAuthorIds: removeAuthorIds, + addAuthorIds: addAuthorIds, + removeCategoryIds: removeCategoryIds, + addCategoryIds: addCategoryIds, + removePublisherIds: removePublisherIds, + addPublisherIds: addPublisherIds + ); + return const Success(null); + } catch (e) { + return Failure(e); + } + } + + @override + ResultFuture deleteBook({ required int bookId }) async { + try { + await _bookDatasource.deleteBook(bookId: bookId); + return const Success(null); + } catch (e) { return Failure(e); } } diff --git a/flutter/lib/src/features/books/domain/entities/book_author_entity.dart b/flutter/lib/src/features/books/domain/entities/book_author_entity.dart new file mode 100644 index 0000000..ea73216 --- /dev/null +++ b/flutter/lib/src/features/books/domain/entities/book_author_entity.dart @@ -0,0 +1,24 @@ +import 'package:equatable/equatable.dart'; + +class BookAuthorEntity extends Equatable { + + final int id; + final String firstName; + final String lastName; + final String? title; + + const BookAuthorEntity({ + required this.id, + required this.firstName, + required this.lastName, + this.title + }); + + @override + List get props => [ + id, + firstName, + lastName, + title + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/books/domain/entities/book_borrow_customer_entity.dart b/flutter/lib/src/features/books/domain/entities/book_borrow_customer_entity.dart new file mode 100644 index 0000000..56ac6f1 --- /dev/null +++ b/flutter/lib/src/features/books/domain/entities/book_borrow_customer_entity.dart @@ -0,0 +1,24 @@ +import 'package:equatable/equatable.dart'; + +class BookBorrowCustomerEntity extends Equatable { + + final int id; + final String? title; + final String firstName; + final String lastName; + + const BookBorrowCustomerEntity({ + required this.id, + this.title, + required this.firstName, + required this.lastName + }); + + @override + List get props => [ + id, + title, + firstName, + lastName + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/books/domain/entities/book_borrow_entity.dart b/flutter/lib/src/features/books/domain/entities/book_borrow_entity.dart new file mode 100644 index 0000000..1486e00 --- /dev/null +++ b/flutter/lib/src/features/books/domain/entities/book_borrow_entity.dart @@ -0,0 +1,27 @@ +import 'package:equatable/equatable.dart'; + +import 'package:habib_app/core/utils/enums/borrow_status.dart'; +import 'package:habib_app/src/features/books/domain/entities/book_borrow_customer_entity.dart'; + +class BookBorrowEntity extends Equatable { + + final int id; + final DateTime endDate; + final BorrowStatus status; + final BookBorrowCustomerEntity customer; + + const BookBorrowEntity({ + required this.id, + required this.endDate, + required this.status, + required this.customer + }); + + @override + List get props => [ + id, + endDate, + status, + customer + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/books/domain/entities/book_category_entity.dart b/flutter/lib/src/features/books/domain/entities/book_category_entity.dart new file mode 100644 index 0000000..e5e4b8f --- /dev/null +++ b/flutter/lib/src/features/books/domain/entities/book_category_entity.dart @@ -0,0 +1,18 @@ +import 'package:equatable/equatable.dart'; + +class BookCategoryEntity extends Equatable { + + final int id; + final String name; + + const BookCategoryEntity({ + required this.id, + required this.name + }); + + @override + List get props => [ + id, + name + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/books/domain/entities/book_details_author_entity.dart b/flutter/lib/src/features/books/domain/entities/book_details_author_entity.dart new file mode 100644 index 0000000..6473c1c --- /dev/null +++ b/flutter/lib/src/features/books/domain/entities/book_details_author_entity.dart @@ -0,0 +1,24 @@ +import 'package:equatable/equatable.dart'; + +class BookDetailsAuthorEntity extends Equatable { + + final int id; + final String? title; + final String firstName; + final String lastName; + + const BookDetailsAuthorEntity({ + required this.id, + this.title, + required this.firstName, + required this.lastName + }); + + @override + List get props => [ + id, + title, + firstName, + lastName + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/books/domain/entities/book_details_category_entity.dart b/flutter/lib/src/features/books/domain/entities/book_details_category_entity.dart new file mode 100644 index 0000000..298a786 --- /dev/null +++ b/flutter/lib/src/features/books/domain/entities/book_details_category_entity.dart @@ -0,0 +1,18 @@ +import 'package:equatable/equatable.dart'; + +class BookDetailsCategoryEntity extends Equatable { + + final int id; + final String name; + + const BookDetailsCategoryEntity({ + required this.id, + required this.name + }); + + @override + List get props => [ + id, + name + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/books/domain/entities/book_details_entity.dart b/flutter/lib/src/features/books/domain/entities/book_details_entity.dart new file mode 100644 index 0000000..0ce4257 --- /dev/null +++ b/flutter/lib/src/features/books/domain/entities/book_details_entity.dart @@ -0,0 +1,46 @@ +import 'package:equatable/equatable.dart'; + +import 'package:habib_app/src/features/books/domain/entities/book_details_author_entity.dart'; +import 'package:habib_app/src/features/books/domain/entities/book_details_category_entity.dart'; +import 'package:habib_app/src/features/books/domain/entities/book_details_publisher_entity.dart'; + +class BookDetailsEntity extends Equatable { + + final int id; + final String title; + final String? isbn10; + final String? isbn13; + final int? edition; + final DateTime? publishDate; + final bool? bought; + final DateTime receivedAt; + final List authors; + final List categories; + final List publishers; + + const BookDetailsEntity({ + required this.id, + required this.title, + this.isbn10, + this.isbn13, + this.edition, + this.publishDate, + this.bought, + required this.receivedAt, + required this.authors, + required this.categories, + required this.publishers + }); + + @override + List get props => [ + id, + title, + isbn10, + isbn13, + edition, + publishDate, + bought, + receivedAt + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/books/domain/entities/book_details_publisher_entity.dart b/flutter/lib/src/features/books/domain/entities/book_details_publisher_entity.dart new file mode 100644 index 0000000..f2c27ad --- /dev/null +++ b/flutter/lib/src/features/books/domain/entities/book_details_publisher_entity.dart @@ -0,0 +1,21 @@ +import 'package:equatable/equatable.dart'; + +class BookDetailsPublisherEntity extends Equatable { + + final int id; + final String name; + final String? city; + + const BookDetailsPublisherEntity({ + required this.id, + required this.name, + this.city + }); + + @override + List get props => [ + id, + name, + city + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/books/domain/entities/book_entity.dart b/flutter/lib/src/features/books/domain/entities/book_entity.dart index 50de377..3b9cb30 100644 --- a/flutter/lib/src/features/books/domain/entities/book_entity.dart +++ b/flutter/lib/src/features/books/domain/entities/book_entity.dart @@ -1,8 +1,8 @@ import 'package:equatable/equatable.dart'; import 'package:habib_app/core/utils/enums/book_status.dart'; -import 'package:habib_app/src/features/books/domain/entities/author_entity.dart'; -import 'package:habib_app/src/features/books/domain/entities/category_entity.dart'; +import 'package:habib_app/src/features/books/domain/entities/book_author_entity.dart'; +import 'package:habib_app/src/features/books/domain/entities/book_category_entity.dart'; class BookEntity extends Equatable { @@ -11,8 +11,8 @@ class BookEntity extends Equatable { final String? isbn10; final String? isbn13; final int? edition; - final List? authors; - final List? categories; + final List? authors; + final List? categories; final BookStatus status; const BookEntity({ diff --git a/flutter/lib/src/features/books/domain/repositories/book_repository.dart b/flutter/lib/src/features/books/domain/repositories/book_repository.dart index c9ae2ae..a951ba5 100644 --- a/flutter/lib/src/features/books/domain/repositories/book_repository.dart +++ b/flutter/lib/src/features/books/domain/repositories/book_repository.dart @@ -1,5 +1,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:habib_app/src/features/books/domain/entities/book_details_entity.dart'; +import 'package:habib_app/src/features/books/domain/entities/book_borrow_entity.dart'; import 'package:habib_app/src/features/books/data/datasources/book_datasource.dart'; import 'package:habib_app/core/utils/typedefs.dart'; import 'package:habib_app/src/features/books/data/repositories/book_repository_impl.dart'; @@ -17,4 +19,28 @@ BookRepository bookRepository(BookRepositoryRef ref) { abstract interface class BookRepository { ResultFuture> getBooks({ required String searchText, required int currentPage }); + + ResultFuture> getBookBorrows({ required int bookId, required String searchText, required int currentPage }); + + ResultFuture getBook({ required int bookId }); + + ResultFuture createBook({ + required Json bookJson, + required List authorIds, + required List categoryIds, + required List publisherIds + }); + + ResultFuture updateBook({ + required int bookId, + required Json bookJson, + required List removeAuthorIds, + required List addAuthorIds, + required List removeCategoryIds, + required List addCategoryIds, + required List removePublisherIds, + required List addPublisherIds + }); + + ResultFuture deleteBook({ required int bookId }); } \ No newline at end of file diff --git a/flutter/lib/src/features/books/domain/usecases/book_create_book_usecase.dart b/flutter/lib/src/features/books/domain/usecases/book_create_book_usecase.dart new file mode 100644 index 0000000..2946005 --- /dev/null +++ b/flutter/lib/src/features/books/domain/usecases/book_create_book_usecase.dart @@ -0,0 +1,57 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/books/domain/repositories/book_repository.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'book_create_book_usecase.g.dart'; + +@riverpod +BookCreateBookUsecase bookCreateBookUsecase(BookCreateBookUsecaseRef ref) { + return BookCreateBookUsecase( + bookRepository: ref.read(bookRepositoryProvider) + ); +} + +class BookCreateBookUsecase extends UsecaseWithParams { + + final BookRepository _bookRepository; + + const BookCreateBookUsecase({ + required BookRepository bookRepository + }) : _bookRepository = bookRepository; + + @override + ResultFuture call(BookCreateBookUsecaseParams params) async { + return await _bookRepository.createBook( + bookJson: params.bookJson, + authorIds: params.authorIds, + categoryIds: params.categoryIds, + publisherIds: params.publisherIds + ); + } +} + +class BookCreateBookUsecaseParams extends Equatable { + + final Json bookJson; + final List authorIds; + final List categoryIds; + final List publisherIds; + + const BookCreateBookUsecaseParams({ + required this.bookJson, + required this.authorIds, + required this.categoryIds, + required this.publisherIds + }); + + @override + List get props => [ + bookJson, + authorIds, + categoryIds, + publisherIds + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/books/domain/usecases/book_delete_book_usecase.dart b/flutter/lib/src/features/books/domain/usecases/book_delete_book_usecase.dart new file mode 100644 index 0000000..09ff128 --- /dev/null +++ b/flutter/lib/src/features/books/domain/usecases/book_delete_book_usecase.dart @@ -0,0 +1,41 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/books/domain/repositories/book_repository.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'book_delete_book_usecase.g.dart'; + +@riverpod +BookDeleteBookUsecase bookDeleteBookUsecase(BookDeleteBookUsecaseRef ref) { + return BookDeleteBookUsecase( + bookRepository: ref.read(bookRepositoryProvider) + ); +} + +class BookDeleteBookUsecase extends UsecaseWithParams { + + final BookRepository _bookRepository; + + const BookDeleteBookUsecase({ + required BookRepository bookRepository + }) : _bookRepository = bookRepository; + + @override + ResultFuture call(BookDeleteBookUsecaseParams params) async { + return await _bookRepository.deleteBook(bookId: params.bookId); + } +} + +class BookDeleteBookUsecaseParams extends Equatable { + + final int bookId; + + const BookDeleteBookUsecaseParams({ required this.bookId }); + + @override + List get props => [ + bookId + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/books/domain/usecases/book_get_book_borrows_usecase.dart b/flutter/lib/src/features/books/domain/usecases/book_get_book_borrows_usecase.dart new file mode 100644 index 0000000..ddb3977 --- /dev/null +++ b/flutter/lib/src/features/books/domain/usecases/book_get_book_borrows_usecase.dart @@ -0,0 +1,54 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/src/features/books/domain/entities/book_borrow_entity.dart'; +import 'package:habib_app/src/features/books/domain/repositories/book_repository.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'book_get_book_borrows_usecase.g.dart'; + +@riverpod +BookGetBookBorrowsUsecase bookGetBookBorrowsUsecase(BookGetBookBorrowsUsecaseRef ref) { + return BookGetBookBorrowsUsecase( + bookRepository: ref.read(bookRepositoryProvider) + ); +} + +class BookGetBookBorrowsUsecase extends UsecaseWithParams, BookGetBookBorrowsUsecaseParams> { + + final BookRepository _bookRepository; + + const BookGetBookBorrowsUsecase({ + required BookRepository bookRepository + }) : _bookRepository = bookRepository; + + @override + ResultFuture> call(BookGetBookBorrowsUsecaseParams params) async { + return await _bookRepository.getBookBorrows( + bookId: params.bookId, + searchText: params.searchText, + currentPage: params.currentPage + ); + } +} + +class BookGetBookBorrowsUsecaseParams extends Equatable { + + final int bookId; + final String searchText; + final int currentPage; + + const BookGetBookBorrowsUsecaseParams({ + required this.bookId, + required this.searchText, + required this.currentPage + }); + + @override + List get props => [ + bookId, + searchText, + currentPage + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/books/domain/usecases/book_get_book_usecase.dart b/flutter/lib/src/features/books/domain/usecases/book_get_book_usecase.dart new file mode 100644 index 0000000..4ce241b --- /dev/null +++ b/flutter/lib/src/features/books/domain/usecases/book_get_book_usecase.dart @@ -0,0 +1,42 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/books/domain/entities/book_details_entity.dart'; +import 'package:habib_app/src/features/books/domain/repositories/book_repository.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'book_get_book_usecase.g.dart'; + +@riverpod +BookGetBookUsecase bookGetBookUsecase(BookGetBookUsecaseRef ref) { + return BookGetBookUsecase( + bookRepository: ref.read(bookRepositoryProvider) + ); +} + +class BookGetBookUsecase extends UsecaseWithParams { + + final BookRepository _bookRepository; + + const BookGetBookUsecase({ + required BookRepository bookRepository + }) : _bookRepository = bookRepository; + + @override + ResultFuture call(BookGetBookUsecaseParams params) async { + return await _bookRepository.getBook(bookId: params.bookId); + } +} + +class BookGetBookUsecaseParams extends Equatable { + + final int bookId; + + const BookGetBookUsecaseParams({ required this.bookId }); + + @override + List get props => [ + bookId + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/books/domain/usecases/book_update_book_usecase.dart b/flutter/lib/src/features/books/domain/usecases/book_update_book_usecase.dart new file mode 100644 index 0000000..f572a8f --- /dev/null +++ b/flutter/lib/src/features/books/domain/usecases/book_update_book_usecase.dart @@ -0,0 +1,73 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/books/domain/repositories/book_repository.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'book_update_book_usecase.g.dart'; + +@riverpod +BookUpdateBookUsecase bookUpdateBookUsecase(BookUpdateBookUsecaseRef ref) { + return BookUpdateBookUsecase( + bookRepository: ref.read(bookRepositoryProvider) + ); +} + +class BookUpdateBookUsecase extends UsecaseWithParams { + + final BookRepository _bookRepository; + + const BookUpdateBookUsecase({ + required BookRepository bookRepository + }) : _bookRepository = bookRepository; + + @override + ResultFuture call(BookUpdateBookUsecaseParams params) async { + return await _bookRepository.updateBook( + bookId: params.bookId, + bookJson: params.bookJson, + removeAuthorIds: params.removeAuthorIds, + addAuthorIds: params.addAuthorIds, + removeCategoryIds: params.removeCategoryIds, + addCategoryIds: params.addCategoryIds, + removePublisherIds: params.removePublisherIds, + addPublisherIds: params.addPublisherIds + ); + } +} + +class BookUpdateBookUsecaseParams extends Equatable { + + final int bookId; + final Json bookJson; + final List removeAuthorIds; + final List addAuthorIds; + final List removeCategoryIds; + final List addCategoryIds; + final List removePublisherIds; + final List addPublisherIds; + + const BookUpdateBookUsecaseParams({ + required this.bookId, + required this.bookJson, + required this.removeAuthorIds, + required this.addAuthorIds, + required this.removeCategoryIds, + required this.addCategoryIds, + required this.removePublisherIds, + required this.addPublisherIds + }); + + @override + List get props => [ + bookId, + bookJson, + removeAuthorIds, + addAuthorIds, + removeCategoryIds, + addCategoryIds, + removePublisherIds, + addPublisherIds + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/books/presentation/app/book_borrows_notifier.dart b/flutter/lib/src/features/books/presentation/app/book_borrows_notifier.dart new file mode 100644 index 0000000..ae44cf0 --- /dev/null +++ b/flutter/lib/src/features/books/presentation/app/book_borrows_notifier.dart @@ -0,0 +1,113 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/core/common/models/error_details.dart'; +import 'package:habib_app/src/features/books/domain/entities/book_borrow_entity.dart'; +import 'package:habib_app/src/features/books/domain/usecases/book_get_book_borrows_usecase.dart'; +import 'package:habib_app/core/utils/constants/network_constants.dart'; +import 'package:habib_app/core/utils/result.dart'; + +part 'book_borrows_notifier.g.dart'; + +@riverpod +class BookBorrowsNotifier extends _$BookBorrowsNotifier { + + late BookGetBookBorrowsUsecase _bookGetBookBorrowsUsecase; + + @override + BookBorrowsState build(int bookId) { + _bookGetBookBorrowsUsecase = ref.read(bookGetBookBorrowsUsecaseProvider); + return const BookBorrowsState(); + } + + Future fetchNextPage(String searchText) async { + if (state.isLoading || state.hasReachedEnd) return; + + state = state.copyWith( + isBookBorrowsLoading: true, + removeError: true + ); + + final BookGetBookBorrowsUsecaseParams params = BookGetBookBorrowsUsecaseParams( + bookId: bookId, + searchText: searchText, + currentPage: state.currentPage + ); + final Result> result = await _bookGetBookBorrowsUsecase.call(params); + + result.fold( + onSuccess: (List bookBorrows) { + state = state.copyWith( + isBookBorrowsLoading: false, + currentPage: bookBorrows.isEmpty + ? state.currentPage + : state.currentPage + 1, + bookBorrows: List.of(state.bookBorrows)..addAll(bookBorrows), + hasReachedEnd: bookBorrows.length < NetworkConstants.pageSize + ); + }, + onFailure: (Object error, StackTrace stackTrace) { + state = state.copyWith( + isBookBorrowsLoading: false, + error: ErrorDetails( + error: error, + stackTrace: stackTrace + ) + ); + } + ); + } + + Future refresh(String searchText) async { + state = const BookBorrowsState(); + await fetchNextPage(searchText); + } +} + +class BookBorrowsState extends Equatable { + + final bool isBookBorrowsLoading; + final ErrorDetails? error; + final List bookBorrows; + final bool hasReachedEnd; + final int currentPage; + + const BookBorrowsState({ + this.isBookBorrowsLoading = false, + this.error, + this.bookBorrows = const [], + this.hasReachedEnd = false, + this.currentPage = 1 + }); + + bool get hasError => error != null; + bool get isLoading => isBookBorrowsLoading; + bool get hasBooksBorrows => bookBorrows.isNotEmpty; + + BookBorrowsState copyWith({ + bool? isBookBorrowsLoading = false, + ErrorDetails? error, + List? bookBorrows, + bool? hasReachedEnd, + int? currentPage, + bool removeError = false, + bool removeBookBorrows = false + }) { + return BookBorrowsState( + isBookBorrowsLoading: isBookBorrowsLoading ?? this.isBookBorrowsLoading, + error: removeError ? null : error ?? this.error, + bookBorrows: removeBookBorrows ? const [] : bookBorrows ?? this.bookBorrows, + hasReachedEnd: hasReachedEnd ?? this.hasReachedEnd, + currentPage: currentPage ?? this.currentPage + ); + } + + @override + List get props => [ + isBookBorrowsLoading, + error, + bookBorrows, + hasReachedEnd, + currentPage + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/books/presentation/app/book_details_page_notifier.dart b/flutter/lib/src/features/books/presentation/app/book_details_page_notifier.dart new file mode 100644 index 0000000..5d12d8e --- /dev/null +++ b/flutter/lib/src/features/books/presentation/app/book_details_page_notifier.dart @@ -0,0 +1,94 @@ +import 'package:equatable/equatable.dart'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/core/common/models/error_details.dart'; +import 'package:habib_app/src/features/books/domain/entities/book_details_entity.dart'; +import 'package:habib_app/src/features/books/domain/usecases/book_get_book_usecase.dart'; +import 'package:habib_app/core/utils/result.dart'; + +part 'book_details_page_notifier.g.dart'; + +@riverpod +class BookDetailsPageNotifier extends _$BookDetailsPageNotifier { + + late BookGetBookUsecase _bookGetBookUsecase; + + @override + BookDetailsPageState build(int bookId) { + _bookGetBookUsecase = ref.read(bookGetBookUsecaseProvider); + return const BookDetailsPageState(); + } + + void replace(BookDetailsEntity book) { + state = state.copyWith(book: book); + } + + Future fetch() async { + if (state.isLoading) return; + + state = state.copyWith( + isBookLoading: true, + removeError: true + ); + + final BookGetBookUsecaseParams bookParams = BookGetBookUsecaseParams(bookId: bookId); + final Result result = await _bookGetBookUsecase.call(bookParams); + + result.fold( + onSuccess: (BookDetailsEntity book) { + state = state.copyWith( + isBookLoading: false, + book: book + ); + }, + onFailure: (Object error, StackTrace stackTrace) { + state = state.copyWith( + isBookLoading: false, + error: ErrorDetails( + error: error, + stackTrace: stackTrace + ) + ); + } + ); + } +} + +class BookDetailsPageState extends Equatable { + + final bool isBookLoading; + final ErrorDetails? error; + final BookDetailsEntity? book; + + const BookDetailsPageState({ + this.isBookLoading = false, + this.error, + this.book + }); + + bool get hasError => error != null; + bool get isLoading => isBookLoading; + bool get hasBook => book != null; + + BookDetailsPageState copyWith({ + bool? isBookLoading = false, + ErrorDetails? error, + BookDetailsEntity? book, + bool removeError = false, + bool removeBook = false + }) { + return BookDetailsPageState( + isBookLoading: isBookLoading ?? this.isBookLoading, + error: removeError ? null : error ?? this.error, + book: removeBook ? null : book ?? this.book + ); + } + + @override + List get props => [ + isBookLoading, + error, + book + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/books/presentation/app/books_page_notifier.dart b/flutter/lib/src/features/books/presentation/app/books_page_notifier.dart index 9dee98b..97e26ad 100644 --- a/flutter/lib/src/features/books/presentation/app/books_page_notifier.dart +++ b/flutter/lib/src/features/books/presentation/app/books_page_notifier.dart @@ -1,64 +1,14 @@ import 'package:equatable/equatable.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:habib_app/core/utils/constants/network_constants.dart'; -import 'package:habib_app/core/utils/result.dart'; +import 'package:habib_app/core/common/models/error_details.dart'; import 'package:habib_app/src/features/books/domain/entities/book_entity.dart'; import 'package:habib_app/src/features/books/domain/usecases/book_get_books_usecase.dart'; +import 'package:habib_app/core/utils/constants/network_constants.dart'; +import 'package:habib_app/core/utils/result.dart'; part 'books_page_notifier.g.dart'; -enum BooksPageStatus { - initial, - loading, - success, - failure -} - -class BooksPageState extends Equatable { - - final BooksPageStatus status; - final Exception? exception; - final List books; - final bool hasReachedEnd; - final int currentPage; - - const BooksPageState({ - this.status = BooksPageStatus.initial, - this.exception, - this.books = const [], - this.hasReachedEnd = false, - this.currentPage = 1 - }); - - BooksPageState copyWith({ - BooksPageStatus? status, - Exception? exception, - List? books, - bool? hasReachedEnd, - int? currentPage, - bool removeException = false, - bool removeBooks = false - }) { - return BooksPageState( - status: status ?? this.status, - exception: removeException ? null : exception ?? this.exception, - books: removeBooks ? [] : books ?? this.books, - hasReachedEnd: hasReachedEnd ?? this.hasReachedEnd, - currentPage: currentPage ?? this.currentPage - ); - } - - @override - List get props => [ - status, - exception, - books, - hasReachedEnd, - currentPage - ]; -} - @riverpod class BooksPageNotifier extends _$BooksPageNotifier { @@ -71,12 +21,11 @@ class BooksPageNotifier extends _$BooksPageNotifier { } Future fetchNextPage(String searchText) async { - if (state.status == BooksPageStatus.loading) return; - if (state.hasReachedEnd) return; - + if (state.isLoading || state.hasReachedEnd) return; + state = state.copyWith( - status: BooksPageStatus.loading, - removeException: true + isBooksLoading: true, + removeError: true ); final BookGetBooksUsecaseParams params = BookGetBooksUsecaseParams( @@ -88,26 +37,76 @@ class BooksPageNotifier extends _$BooksPageNotifier { result.fold( onSuccess: (List books) { state = state.copyWith( - status: BooksPageStatus.success, + isBooksLoading: false, currentPage: books.isEmpty ? state.currentPage : state.currentPage + 1, books: List.of(state.books)..addAll(books), - hasReachedEnd: books.length < NetworkConstants.pageSize, - removeException: true + hasReachedEnd: books.length < NetworkConstants.pageSize ); - }, - onFailure: (Exception exception, StackTrace stackTrace) { + }, + onFailure: (Object error, StackTrace stackTrace) { state = state.copyWith( - status: BooksPageStatus.failure, - exception: exception + isBooksLoading: false, + error: ErrorDetails( + error: error, + stackTrace: stackTrace + ) ); } ); } - + Future refresh(String searchText) async { state = const BooksPageState(); await fetchNextPage(searchText); } +} + +class BooksPageState extends Equatable { + + final bool isBooksLoading; + final ErrorDetails? error; + final List books; + final bool hasReachedEnd; + final int currentPage; + + const BooksPageState({ + this.isBooksLoading = false, + this.error, + this.books = const [], + this.hasReachedEnd = false, + this.currentPage = 1 + }); + + bool get hasError => error != null; + bool get isLoading => isBooksLoading; + bool get hasBooks => books.isNotEmpty; + + BooksPageState copyWith({ + bool? isBooksLoading = false, + ErrorDetails? error, + List? books, + bool? hasReachedEnd, + int? currentPage, + bool removeError = false, + bool removeBooks = false + }) { + return BooksPageState( + isBooksLoading: isBooksLoading ?? this.isBooksLoading, + error: removeError ? null : error ?? this.error, + books: removeBooks ? const [] : books ?? this.books, + hasReachedEnd: hasReachedEnd ?? this.hasReachedEnd, + currentPage: currentPage ?? this.currentPage + ); + } + + @override + List get props => [ + isBooksLoading, + error, + books, + hasReachedEnd, + currentPage + ]; } \ No newline at end of file diff --git a/flutter/lib/src/features/books/presentation/pages/book_details_page.dart b/flutter/lib/src/features/books/presentation/pages/book_details_page.dart index 0e3218c..80276c1 100644 --- a/flutter/lib/src/features/books/presentation/pages/book_details_page.dart +++ b/flutter/lib/src/features/books/presentation/pages/book_details_page.dart @@ -1,7 +1,43 @@ import 'package:flutter/material.dart'; +import 'package:flutter_conditional/flutter_conditional.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:habib_app/core/common/widgets/hb_checkbox.dart'; +import 'package:habib_app/core/common/widgets/hb_chip.dart'; +import 'package:habib_app/core/common/widgets/hb_date_button.dart'; +import 'package:habib_app/core/common/widgets/hb_light_button.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/core/utils/validator.dart'; +import 'package:habib_app/src/features/authors/presentation/widgets/authors_selection_dialog.dart'; +import 'package:habib_app/src/features/books/domain/entities/book_details_author_entity.dart'; +import 'package:habib_app/src/features/books/domain/entities/book_details_category_entity.dart'; +import 'package:habib_app/src/features/books/domain/entities/book_details_entity.dart'; +import 'package:habib_app/src/features/books/domain/entities/book_details_publisher_entity.dart'; +import 'package:habib_app/src/features/books/domain/usecases/book_update_book_usecase.dart'; +import 'package:habib_app/src/features/books/presentation/pages/create_book_page.dart'; +import 'package:habib_app/src/features/categories/presentation/widgets/categories_selection_dialog.dart'; +import 'package:habib_app/src/features/publishers/presentation/widgets/publishers_selection_dialog.dart'; +import 'package:habib_app/src/features/books/domain/usecases/book_delete_book_usecase.dart'; +import 'package:habib_app/src/features/books/presentation/app/book_details_page_notifier.dart'; +import 'package:habib_app/src/features/books/presentation/widgets/book_borrows_table.dart'; +import 'package:habib_app/core/res/theme/colors/hb_colors.dart'; +import 'package:habib_app/core/res/theme/typography/hb_typography.dart'; +import 'package:habib_app/core/common/widgets/hb_gap.dart'; +import 'package:habib_app/core/common/widgets/hb_text_field.dart'; +import 'package:habib_app/core/extensions/context_extension.dart'; +import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; +import 'package:habib_app/core/common/models/error_details.dart'; +import 'package:habib_app/core/common/widgets/hb_message_box.dart'; +import 'package:habib_app/core/utils/result.dart'; +import 'package:habib_app/core/extensions/object_extension.dart'; +import 'package:habib_app/core/utils/enums/toast_type.dart'; import 'package:habib_app/core/common/widgets/hb_app_bar.dart'; import 'package:habib_app/core/common/widgets/hb_scaffold.dart'; +import 'package:habib_app/core/res/hb_icons.dart'; +import 'package:habib_app/core/utils/core_utils.dart'; class BookDetailsPageParams { @@ -12,7 +48,7 @@ class BookDetailsPageParams { }); } -class BookDetailsPage extends StatelessWidget { +class BookDetailsPage extends StatefulHookConsumerWidget { final BookDetailsPageParams params; @@ -21,13 +57,645 @@ class BookDetailsPage extends StatelessWidget { required this.params }); + @override + ConsumerState createState() => _BookDetailsPageState(); +} + +class _BookDetailsPageState extends ConsumerState { + + late TextEditingController _titleController; + late TextEditingController _isbn10Controller; + late TextEditingController _isbn13Controller; + late TextEditingController _editionController; + late ValueNotifier> _authorsNotifier; + late ValueNotifier> _categoriesNotifier; + late ValueNotifier _publishDateNotifier; + late ValueNotifier> _publishersNotifier; + late ValueNotifier _receivedAtNotifier; + late ValueNotifier _boughtNotifier; + + void _onDetailsStateUpdate(BookDetailsPageState? _, BookDetailsPageState next) { + if (next.hasError) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: next.error!.errorTitle, + description: next.error!.errorDescription, + ); + } + } + + Future _onRefresh() async { + ref.read(bookDetailsPageNotifierProvider(widget.params.bookId).notifier).fetch(); + } + + Future _onSave() async { + final BookDetailsPageState pageState = ref.watch(bookDetailsPageNotifierProvider(widget.params.bookId)); + final BookDetailsEntity book = pageState.book!; + + final String title = _titleController.text.trim(); + final String isbn10 = _isbn10Controller.text.trim(); + final String isbn13 = _isbn13Controller.text.trim(); + final int? edition = _editionController.text.trim().isNotEmpty + ? int.parse(_editionController.text.trim()) + : null; + final List authorIds = _authorsNotifier.value.map((e) => e.id).toList(); + final List categoryIds = _categoriesNotifier.value.map((e) => e.id).toList(); + final DateTime? publishDate = _publishDateNotifier.value; + final List publisherIds = _publishersNotifier.value.map((e) => e.id).toList(); + final DateTime receivedAt = _receivedAtNotifier.value; + final bool bought = _boughtNotifier.value; + + try { + Validator.validateBookUpdate( + title: title, + isbn10: isbn10, + isbn13: isbn13, + edition: edition, + authorIds: authorIds, + categoriesIds: categoryIds, + publishDate: publishDate, + publisherIds: publisherIds, + receivedAt: receivedAt, + bought: bought + ); + } catch (e) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: e.errorTitle, + description: e.errorDescription, + ); + return; + } + + final List beforeAuthorIds = book.authors.map((e) => e.id).toList(); + final List beforeCategoryIds = book.categories.map((e) => e.id).toList(); + final List beforePublisherIds = book.publishers.map((e) => e.id).toList(); + + final bool replaceTitle = title != book.title; + final bool replaceIsbn10 = isbn10 != (book.isbn10 ?? ''); + final bool replaceIsbn13 = isbn13 != (book.isbn13 ?? ''); + final bool replaceEdition = edition != book.edition; + final bool replaceAuthors = !CoreUtils.intListsHaveSameContents(authorIds, beforeAuthorIds); + final bool replaceCategories = !CoreUtils.intListsHaveSameContents(categoryIds, beforeCategoryIds); + final bool replacePublishDate = publishDate != book.publishDate; + final bool replacePublishers = !CoreUtils.intListsHaveSameContents(publisherIds, beforePublisherIds); + final bool replaceReceivedAt = receivedAt != book.receivedAt; + final bool replaceBought = bought != book.bought; + + Json bookJson = { + if (replaceTitle) 'title' : "'$title'", + if (replaceIsbn10) 'isbn_10' : "'$isbn10'", + if (replaceIsbn13) 'isbn_13' : "'$isbn13'", + if (replaceEdition) 'edition' : edition, + if (replacePublishDate) 'publish_date' : publishDate != null + ? "'${ publishDate.toIso8601String() }'" + : null, + if (replaceReceivedAt) 'received_at' : "'${ receivedAt.toIso8601String() }'", + if (replaceBought) 'bought' : bought ? 1 : 0 + }; + + final (List removeAuthorIds, List addAuthorIds) authorDiffs = CoreUtils.findUniqueElements(beforePublisherIds, publisherIds); + final (List removeCategoryIds, List addCategoryIds) categoryDiffs = CoreUtils.findUniqueElements(beforeCategoryIds, categoryIds); + final (List removePublisherIds, List addPublisherIds) publisherDiffs = CoreUtils.findUniqueElements(beforePublisherIds, publisherIds); + + if (bookJson.isEmpty && !replaceAuthors && !replaceCategories && !replacePublishers) return; + + final BookUpdateBookUsecase bookUpdateBookUsecase = ref.read(bookUpdateBookUsecaseProvider); + final BookUpdateBookUsecaseParams bookUpdateBookUsecaseParams = BookUpdateBookUsecaseParams( + bookId: book.id, + bookJson: bookJson, + removeAuthorIds: authorDiffs.$1, + addAuthorIds: authorDiffs.$2, + removeCategoryIds: categoryDiffs.$1, + addCategoryIds: categoryDiffs.$2, + removePublisherIds: publisherDiffs.$1, + addPublisherIds: publisherDiffs.$2 + ); + final Result bookUpdateBookUsecaseResult = await bookUpdateBookUsecase.call(bookUpdateBookUsecaseParams); + + bookUpdateBookUsecaseResult.fold( + onSuccess: (void _) { + ref.read(bookDetailsPageNotifierProvider(widget.params.bookId).notifier).replace( + BookDetailsEntity( + id: book.id, + title: replaceTitle ? title : book.title, + isbn10: replaceIsbn10 ? isbn10 : book.isbn10, + isbn13: replaceIsbn13 ? isbn13 : book.isbn13, + edition: replaceEdition ? edition : book.edition, + publishDate: replacePublishDate ? publishDate : book.publishDate, + bought: replaceBought ? bought : book.bought, + receivedAt: replaceReceivedAt ? receivedAt : book.receivedAt, + publishers: replacePublishers ? _publishersNotifier.value : book.publishers, + categories: replaceCategories ? _categoriesNotifier.value : book.categories, + authors: replaceAuthors ? _authorsNotifier.value : book.authors + ) + ); + + CoreUtils.showToast( + context, + type: ToastType.success, + title: 'Erfolgreich aktualisiert.', + description: 'Das Buch wurde erfolgreich aktualisiert.' + ); + }, + onFailure: (Object error, StackTrace stackTrace) { + final ErrorDetails errorDetails = ErrorDetails( + error: error, + stackTrace: stackTrace + ); + CoreUtils.showToast( + context, + type: ToastType.error, + title: errorDetails.errorTitle, + description: errorDetails.errorDescription, + ); + } + ); + } + + Future _onDelete() async { + final bool? success = await showHBMessageBox( + context, + 'Dieses Buch wirklich löschen?', + 'Wenn Sie dieses Buch löschen, werden alle damit verbundenen Daten ebenfalls gelöscht. Dieser Vorgang kann nicht rückgängig gemacht werden.', + 'Löschen', + onPressed: () async { + final BookDeleteBookUsecase bookDeleteBookUsecase = ref.read(bookDeleteBookUsecaseProvider); + final BookDeleteBookUsecaseParams bookDeleteBookUsecaseParams = BookDeleteBookUsecaseParams(bookId: widget.params.bookId); + final Result bookDeleteBookUsecaseResult = await bookDeleteBookUsecase.call(bookDeleteBookUsecaseParams); + + return bookDeleteBookUsecaseResult.fold( + onSuccess: (void _) => true, + onFailure: (Object error, StackTrace stackTrace) { + final ErrorDetails errorDetails = ErrorDetails( + error: error, + stackTrace: stackTrace + ); + CoreUtils.showToast( + context, + type: ToastType.error, + title: errorDetails.errorTitle, + description: errorDetails.errorDescription, + ); + return false; + } + ); + } + ); + + if (mounted && context.canPop() && (success ?? false)) context.pop(true); + } + + void _handlePublishDateChanged(DateTime newDate) { + _publishDateNotifier.value = newDate; + } + + void _handleReceivedAtDateChanged(DateTime newDate) { + _receivedAtNotifier.value = newDate; + } + + void _handleBoughtChanged(bool? newBought) { + _boughtNotifier.value = newBought ?? false; + } + + Future _handleEditAuthors() async { + List? newAuthors = await showAuthorsSelectionDialog( + context: context, + authors: _authorsNotifier.value.map((e) + => CreateBookAuthor( + id: e.id, + firstName: e.firstName, + lastName: e.lastName + )).toList() + ); + + if (newAuthors != null) { + _authorsNotifier.value = newAuthors.map((e) + => BookDetailsAuthorEntity( + id: e.id, + title: e.title, + firstName: e.firstName, + lastName: e.lastName + )).toList(); + } + } + + Future _handleEditCategories() async { + List? newCategories = await showCategoriesSelectionDialog( + context: context, + categories: _categoriesNotifier.value.map((e) + => CreateBookCategory( + id: e.id, + name: e.name + )).toList() + ); + + if (newCategories != null) { + _categoriesNotifier.value = newCategories.map((e) + => BookDetailsCategoryEntity( + id: e.id, + name: e.name + )).toList(); + } + } + + Future _handleEditPublishers() async { + List? newPublishers = await showPublishersSelectionDialog( + context: context, + publishers: _publishersNotifier.value.map((e) + => CreateBookPublisher( + id: e.id, + name: e.name, + city: e.city + )).toList() + ); + + if (newPublishers != null) { + _publishersNotifier.value = newPublishers.map((e) + => BookDetailsPublisherEntity( + id: e.id, + name: e.name, + city: e.city + )).toList(); + } + } + + @override + void initState() { + super.initState(); + + CoreUtils.postFrameCall(() { + ref.read(bookDetailsPageNotifierProvider(widget.params.bookId).notifier).fetch(); + }); + } + @override Widget build(BuildContext context) { + + final BookDetailsPageState pageState = ref.watch(bookDetailsPageNotifierProvider(widget.params.bookId)); + + if (pageState.hasBook) { + _titleController = useTextEditingController(text: pageState.book!.title); + _isbn10Controller = useTextEditingController(text: pageState.book!.isbn10); + _isbn13Controller = useTextEditingController(text: pageState.book!.isbn13); + _editionController = useTextEditingController(text: pageState.book!.edition?.toString()); + _authorsNotifier = useState>(pageState.book!.authors); + _categoriesNotifier = useState>(pageState.book!.categories); + _publishDateNotifier = useState(pageState.book!.publishDate); + _publishersNotifier = useState>(pageState.book!.publishers); + _receivedAtNotifier = useState(pageState.book!.receivedAt); + _boughtNotifier = useState(pageState.book!.bought ?? false); + } + + ref.listen( + bookDetailsPageNotifierProvider(widget.params.bookId), + _onDetailsStateUpdate + ); + return HBScaffold( appBar: HBAppBar( context: context, title: 'Details', - backButton: const HBAppBarBackButton() + backButton: const HBAppBarBackButton(), + actionButtons: [ + HBAppBarButton( + onPressed: _onRefresh, + icon: HBIcons.arrowPath, + isEnabled: !pageState.isLoading + ), + HBAppBarButton( + onPressed: _onSave, + icon: HBIcons.cloudArrowUp, + isEnabled: pageState.hasBook, + ), + HBAppBarButton( + onPressed: _onDelete, + icon: HBIcons.trash, + isEnabled: pageState.hasBook + ) + ] + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: pageState.hasBook + ? SingleChildScrollView( + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + top: HBSpacing.lg + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Allgemeine Details', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 20.0, + fontWeight: FontWeight.w600, + color: HBColors.gray900 + ) + ), + const HBGap.xl(), + Row( + children: [ + Expanded( + child: HBTextField( + title: 'Titel', + controller: _titleController, + icon: HBIcons.home + ) + ), + const HBGap.xl(), + Expanded( + child: HBTextField( + title: 'ISBN-10', + controller: _isbn10Controller, + icon: HBIcons.home + ) + ), + const HBGap.xl(), + Expanded( + child: HBTextField( + title: 'ISBN-13', + controller: _isbn13Controller, + icon: HBIcons.home + ) + ) + ] + ), + const HBGap.xl(), + Row( + children: [ + Expanded( + child: HBTextField( + title: 'Edition', + inputType: TextInputType.number, + controller: _editionController, + icon: HBIcons.home + ) + ), + const HBGap.xl(), + const Spacer(), + const HBGap.xl(), + const Spacer() + ] + ), + const HBGap.xl(), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Autoren', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 16.0, + fontWeight: FontWeight.w400, + color: HBColors.gray900 + ) + ) + ), + const HBGap.md(), + HBLightButton( + onPressed: _handleEditAuthors, + isEnabled: pageState.hasBook, + textAlign: TextAlign.end, + title: 'Bearbeiten' + ) + ] + ), + const HBGap.md(), + Conditional.single( + condition: _authorsNotifier.value.isNotEmpty, + widget: HBChips( + chips: List.generate(_authorsNotifier.value.length, (int index) { + final BookDetailsAuthorEntity author = _authorsNotifier.value[index]; + return HBChip( + text: '${ author.title != null ? '${ author.title} ' : '' } ${ author.firstName } ${ author.lastName }', + color: HBColors.gray900 + ); + }) + ), + fallback: Text( + 'Keine Autoren gewählt', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 14.0, + fontWeight: FontWeight.w600 + ) + ) + ) + ] + ) + ), + const HBGap.xl(), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Kategorien', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 16.0, + fontWeight: FontWeight.w400, + color: HBColors.gray900 + ) + ) + ), + const HBGap.md(), + HBLightButton( + onPressed: _handleEditCategories, + isEnabled: pageState.hasBook, + textAlign: TextAlign.end, + title: 'Bearbeiten' + ) + ] + ), + const HBGap.md(), + Conditional.single( + condition: _categoriesNotifier.value.isNotEmpty, + widget: HBChips( + chips: List.generate(_categoriesNotifier.value.length, (int index) { + final BookDetailsCategoryEntity category = _categoriesNotifier.value[index]; + return HBChip( + text: category.name, + color: HBColors.gray900 + ); + }) + ), + fallback: Text( + 'Keine Kategorien gewählt', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 14.0, + fontWeight: FontWeight.w600 + ) + ) + ) + ] + ) + ), + const HBGap.xl(), + const Spacer() + ] + ), + const HBGap.xxl(), + Text( + 'Verlagsinformationen', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 20.0, + fontWeight: FontWeight.w600, + color: HBColors.gray900 + ) + ), + const HBGap.xl(), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: HBDateButton( + onChanged: _handlePublishDateChanged, + title: 'Veröffentlichung', + dateTime: _publishDateNotifier.value + ) + ), + const HBGap.xl(), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Verläge', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 16.0, + fontWeight: FontWeight.w400, + color: HBColors.gray900 + ) + ) + ), + const HBGap.md(), + HBLightButton( + onPressed: _handleEditPublishers, + isEnabled: pageState.hasBook, + textAlign: TextAlign.end, + title: 'Bearbeiten' + ) + ] + ), + const HBGap.md(), + Conditional.single( + condition: _publishersNotifier.value.isNotEmpty, + widget: HBChips( + chips: List.generate(_publishersNotifier.value.length, (int index) { + final BookDetailsPublisherEntity publisher = _publishersNotifier.value[index]; + return HBChip( + text: '${ publisher.name }${ publisher.city != null ? ' (${ publisher.city })' : '' }', + color: HBColors.gray900 + ); + }) + ), + fallback: Text( + 'Keine Verläge gewählt', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 14.0, + fontWeight: FontWeight.w600 + ) + ) + ) + ] + ) + ), + const HBGap.xl(), + const Spacer() + ] + ), + const HBGap.xxl(), + Text( + 'Anlageinformationen', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 20.0, + fontWeight: FontWeight.w600, + color: HBColors.gray900 + ) + ), + const HBGap.xl(), + Row( + children: [ + Expanded( + child: HBDateButton( + onChanged: _handleReceivedAtDateChanged, + title: 'Erhalten', + dateTime: _receivedAtNotifier.value + ) + ), + const HBGap.xl(), + Expanded( + child: Center( + child: HBCheckbox( + onChanged: _handleBoughtChanged, + text: 'Ist gekauft?', + isSelected: _boughtNotifier.value + ) + ) + ), + const Spacer() + ] + ) + ] + ) + ) + : pageState.hasError + ? Center( + child: Text( + pageState.error!.errorDescription, + textAlign: TextAlign.center, + style: HBTypography.base.copyWith( + fontSize: 14.0, + fontWeight: FontWeight.w600, + color: HBColors.gray900 + ), + ) + ) + : const SizedBox.shrink() + ), + const Divider(), + Expanded( + flex: 2, + child: BookBorrowsTable(bookId: widget.params.bookId) + ) + ] ) ); } diff --git a/flutter/lib/src/features/books/presentation/pages/books_page.dart b/flutter/lib/src/features/books/presentation/pages/books_page.dart index ef4223a..2182a7d 100644 --- a/flutter/lib/src/features/books/presentation/pages/books_page.dart +++ b/flutter/lib/src/features/books/presentation/pages/books_page.dart @@ -1,19 +1,19 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:habib_app/core/common/widgets/hb_chip.dart'; -import 'package:habib_app/src/features/books/domain/entities/author_entity.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:habib_app/core/extensions/object_extension.dart'; +import 'package:habib_app/core/common/widgets/hb_chip.dart'; +import 'package:habib_app/src/features/books/domain/entities/book_author_entity.dart'; import 'package:habib_app/src/features/books/domain/entities/book_entity.dart'; import 'package:habib_app/core/common/widgets/hb_table.dart'; -import 'package:habib_app/core/extensions/exception_extension.dart'; import 'package:habib_app/core/utils/enums/toast_type.dart'; import 'package:habib_app/core/common/widgets/hb_app_bar.dart'; import 'package:habib_app/core/common/widgets/hb_button.dart'; import 'package:habib_app/core/common/widgets/hb_gap.dart'; import 'package:habib_app/core/common/widgets/hb_scaffold.dart'; -import 'package:habib_app/core/common/widgets/sc_text_field.dart'; +import 'package:habib_app/core/common/widgets/hb_text_field.dart'; import 'package:habib_app/core/extensions/context_extension.dart'; import 'package:habib_app/core/res/hb_icons.dart'; import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; @@ -37,12 +37,12 @@ class _BooksPageState extends ConsumerState { late TextEditingController _searchController; void _onPageStateUpdate(BooksPageState? _, BooksPageState next) { - if (next.exception != null) { + if (next.hasError) { CoreUtils.showToast( context, type: ToastType.error, - title: next.exception!.title(context), - description: next.exception!.description(context) + title: next.error!.errorTitle, + description: next.error!.errorDescription, ); } } @@ -62,15 +62,15 @@ class _BooksPageState extends ConsumerState { HBTableStatus get _tableStatus { final BooksPageState pageState = ref.read(booksPageNotifierProvider); - if (pageState.status == BooksPageStatus.success && pageState.books.isNotEmpty) return HBTableStatus.data; - if (pageState.status == BooksPageStatus.failure || (pageState.status == BooksPageStatus.success && pageState.books.isEmpty)) return HBTableStatus.text; + if (pageState.hasBooks) return HBTableStatus.data; + if (pageState.hasError || !pageState.hasBooks) return HBTableStatus.text; return HBTableStatus.loading; } String? get _tableText { final BooksPageState pageState = ref.read(booksPageNotifierProvider); - if (pageState.status == BooksPageStatus.success && pageState.books.isEmpty) return 'Keine Bücher gefunden.'; - if (pageState.status == BooksPageStatus.failure) return 'Ein Fehler ist aufgetreten.'; + if (!pageState.isLoading && !pageState.hasError && !pageState.hasBooks) return 'Keine Bücher gefunden.'; + if (pageState.hasError) return 'Ein Fehler ist aufgetreten.'; return null; } @@ -86,7 +86,7 @@ class _BooksPageState extends ConsumerState { await const CreateBookRoute().push(context); } - Future _onSearchChanged() async { + Future _onSearchChanged(String _) async { await ref.read(booksPageNotifierProvider.notifier).refresh(_searchText); } @@ -143,7 +143,7 @@ class _BooksPageState extends ConsumerState { children: [ HBTextField( controller: _searchController, - onChanged: (String _) => _onSearchChanged, + onChanged: _onSearchChanged, icon: HBIcons.magnifyingGlass, hint: 'Buchtitel oder ISBN', maxWidth: 500.0 @@ -182,7 +182,7 @@ class _BooksPageState extends ConsumerState { final BookEntity book = pageState.books[index]; return [ HBTableText(text: '${ book.title }${ book.edition != null ? ' (${ book.edition }. Auflage)' : '' }'), - HBTableText(text: (book.authors ?? []).map((AuthorEntity author) => '${ author.title != null ? '${ author.title } ' : '' } ${ author.firstName } ${ author.lastName }').join(', ')), + HBTableText(text: (book.authors ?? []).map((BookAuthorEntity author) => '${ author.title != null ? '${ author.title } ' : '' }${ author.firstName } ${ author.lastName }').join(', ')), HBTableText(text: book.isbn10 ?? ''), HBTableText(text: book.isbn13 ?? ''), HBTableChip( diff --git a/flutter/lib/src/features/books/presentation/pages/create_book_page.dart b/flutter/lib/src/features/books/presentation/pages/create_book_page.dart index 2c129ce..772d865 100644 --- a/flutter/lib/src/features/books/presentation/pages/create_book_page.dart +++ b/flutter/lib/src/features/books/presentation/pages/create_book_page.dart @@ -1,28 +1,494 @@ import 'package:flutter/material.dart'; +import 'package:flutter_conditional/flutter_conditional.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:habib_app/core/common/widgets/hb_chip.dart'; +import 'package:habib_app/core/common/models/error_details.dart'; +import 'package:habib_app/core/extensions/object_extension.dart'; +import 'package:habib_app/core/services/routes.dart'; +import 'package:habib_app/core/utils/core_utils.dart'; +import 'package:habib_app/core/utils/enums/toast_type.dart'; +import 'package:habib_app/core/utils/result.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/core/utils/validator.dart'; +import 'package:habib_app/src/features/books/domain/usecases/book_create_book_usecase.dart'; +import 'package:habib_app/src/features/publishers/presentation/widgets/publishers_selection_dialog.dart'; +import 'package:habib_app/src/features/authors/presentation/widgets/authors_selection_dialog.dart'; +import 'package:habib_app/src/features/categories/presentation/widgets/categories_selection_dialog.dart'; +import 'package:habib_app/core/common/widgets/hb_light_button.dart'; +import 'package:habib_app/core/res/theme/colors/hb_colors.dart'; +import 'package:habib_app/core/res/theme/typography/hb_typography.dart'; +import 'package:habib_app/core/common/widgets/hb_checkbox.dart'; +import 'package:habib_app/core/common/widgets/hb_date_button.dart'; import 'package:habib_app/core/common/widgets/hb_dialog.dart'; +import 'package:habib_app/core/common/widgets/hb_gap.dart'; +import 'package:habib_app/core/common/widgets/hb_text_field.dart'; +import 'package:habib_app/core/res/hb_icons.dart'; -class CreateBookPage extends StatefulHookWidget { +class CreateBookAuthor { + + final int id; + final String? title; + final String firstName; + final String lastName; + + const CreateBookAuthor({ + required this.id, + this.title, + required this.firstName, + required this.lastName + }); + + String toOneLine() { + return '${ title != null ? '$title ' : '' } $firstName $lastName'; + } +} + +class CreateBookCategory { + + final int id; + final String name; + + const CreateBookCategory({ + required this.id, + required this.name + }); + + String toOneLine() { + return name; + } +} + +class CreateBookPublisher { + + final int id; + final String name; + final String? city; + + const CreateBookPublisher({ + required this.id, + required this.name, + this.city + }); + + String toOneLine() { + return '$name${ city != null ? ' ($city)' : '' }'; + } +} + +class CreateBookPage extends StatefulHookConsumerWidget { const CreateBookPage({ super.key }); @override - State createState() => _CreateBookPageState(); + ConsumerState createState() => _CreateBookPageState(); } -class _CreateBookPageState extends State { +class _CreateBookPageState extends ConsumerState { + + late TextEditingController _titleController; + late TextEditingController _isbn10Controller; + late TextEditingController _isbn13Controller; + late ValueNotifier> _authorsNotifier; + late ValueNotifier> _categoriesNotifier; + late TextEditingController _editionController; + late ValueNotifier _publishDateNotifier; + late ValueNotifier> _publishersNotifier; + late ValueNotifier _boughtNotifier; + late ValueNotifier _receivedAtNotifier; + + Future _handleCreate() async { + final String title = _titleController.text.trim(); + final String isbn10 = _isbn10Controller.text.trim(); + final String isbn13 = _isbn13Controller.text.trim(); + final List authorIds = _authorsNotifier.value.map((e) => e.id).toList(); + final List categoriesIds = _categoriesNotifier.value.map((e) => e.id).toList(); + final int? edition = _editionController.text.trim().isNotEmpty + ? int.parse(_editionController.text.trim()) + : null; + final DateTime? publishDate = _publishDateNotifier.value; + final List publisherIds = _publishersNotifier.value.map((e) => e.id).toList(); + final bool bought = _boughtNotifier.value; + final DateTime receivedAt = _receivedAtNotifier.value; + + try { + Validator.validateBookCreate( + title: title, + isbn10: isbn10, + isbn13: isbn13, + authorIds: authorIds, + categoriesIds: categoriesIds, + edition: edition, + publishDate: publishDate, + publisherIds: publisherIds, + bought: bought, + receivedAt: receivedAt + ); + } catch (e) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: e.errorTitle, + description: e.errorDescription, + ); + return; + } + + Json bookJson = { + 'title' : "'$title'", + 'isbn_10' : "'$isbn10'", + 'isbn_13' : "'$isbn13'", + 'edition' : edition, + 'publish_date' : publishDate != null + ? "'${ publishDate.toIso8601String() }'" + : null, + 'bought' : bought ? 1 : 0, + 'received_at' : "'${ receivedAt.toIso8601String() }'" + }; + + final BookCreateBookUsecase bookCreateBookUsecase = ref.read(bookCreateBookUsecaseProvider); + final BookCreateBookUsecaseParams bookCreateBookUsecaseParams = BookCreateBookUsecaseParams( + bookJson: bookJson, + authorIds: authorIds, + categoryIds: categoriesIds, + publisherIds: publisherIds + ); + final Result bookCreateBookUsecaseResult = await bookCreateBookUsecase.call(bookCreateBookUsecaseParams); + + bookCreateBookUsecaseResult.fold( + onSuccess: (int bookId) async { + CoreUtils.showToast( + context, + type: ToastType.success, + title: 'Erfolgreich angelegt.', + description: 'Das Buch wurde erfolgreich angelegt.' + ); + + if (!mounted) return; + + context.pop(); + + await BookDetailsRoute(bookId: bookId).push(context); + }, + onFailure: (Object error, StackTrace stackTrace) { + final ErrorDetails errorDetails = ErrorDetails( + error: error, + stackTrace: stackTrace + ); + CoreUtils.showToast( + context, + type: ToastType.error, + title: errorDetails.errorTitle, + description: errorDetails.errorDescription, + ); + } + ); + } + + void _handlePublishDateChanged(DateTime newDate) { + _publishDateNotifier.value = newDate; + } + + void _handleReceivedAtDateChanged(DateTime newDate) { + _receivedAtNotifier.value = newDate; + } + + void _handleBoughtChanged(bool? newBought) { + _boughtNotifier.value = newBought ?? false; + } + + Future _handleEditAuthors() async { + List? newAuthors = await showAuthorsSelectionDialog( + context: context, + authors: _authorsNotifier.value + ); + + if (newAuthors != null) _authorsNotifier.value = newAuthors; + } + + Future _handleEditCategories() async { + List? newCategories = await showCategoriesSelectionDialog( + context: context, + categories: _categoriesNotifier.value + ); + + if (newCategories != null) _categoriesNotifier.value = newCategories; + } + + Future _handleEditPublishers() async { + List? newPublishers = await showPublishersSelectionDialog( + context: context, + publishers: _publishersNotifier.value + ); + + if (newPublishers != null) _publishersNotifier.value = newPublishers; + } @override Widget build(BuildContext context) { + _titleController = useTextEditingController(); + _isbn10Controller = useTextEditingController(); + _isbn13Controller = useTextEditingController(); + _authorsNotifier = useState>([]); + _categoriesNotifier = useState>([]); + _editionController = useTextEditingController(); + _publishDateNotifier = useState(null); + _publishersNotifier = useState>([]); + _boughtNotifier = useState(false); + _receivedAtNotifier = useState(DateTime.now()); + return HBDialog( title: 'Neues Buch', actionButton: HBDialogActionButton( - onPressed: () {}, + onPressed: _handleCreate, title: 'Erstellen' - ) + ), + children: [ + const HBDialogSection( + title: 'Allgemeine Details', + isFirstSection: true + ), + Row( + children: [ + Expanded( + child: HBTextField( + controller: _titleController, + icon: HBIcons.academicCap, + title: 'Titel' + ) + ), + const HBGap.lg(), + Expanded( + child: HBTextField( + controller: _editionController, + inputType: TextInputType.number, + icon: HBIcons.user, + title: 'Edition' + ) + ) + ] + ), + const HBGap.lg(), + Row( + children: [ + Expanded( + child: HBTextField( + controller: _isbn10Controller, + icon: HBIcons.user, + title: 'ISBN-10' + ) + ), + const HBGap.lg(), + Expanded( + child: HBTextField( + controller: _isbn13Controller, + icon: HBIcons.beaker, + title: 'ISBN-13' + ) + ) + ] + ), + const HBGap.lg(), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Autoren', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 16.0, + fontWeight: FontWeight.w400, + color: HBColors.gray900 + ) + ) + ), + const HBGap.md(), + HBLightButton( + onPressed: _handleEditAuthors, + textAlign: TextAlign.end, + title: 'Bearbeiten' + ) + ] + ), + const HBGap.md(), + Conditional.single( + condition: _authorsNotifier.value.isNotEmpty, + widget: HBChips( + chips: List.generate(_authorsNotifier.value.length, (int index) { + final CreateBookAuthor author = _authorsNotifier.value[index]; + return HBChip( + text: author.toOneLine(), + color: HBColors.gray900 + ); + }) + ), + fallback: Text( + 'Keine Autoren gewählt', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 14.0, + fontWeight: FontWeight.w600 + ) + ) + ) + ] + ) + ), + const HBGap.lg(), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Kategorien', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 16.0, + fontWeight: FontWeight.w400, + color: HBColors.gray900 + ) + ) + ), + const HBGap.md(), + HBLightButton( + onPressed: _handleEditCategories, + textAlign: TextAlign.end, + title: 'Bearbeiten' + ) + ] + ), + const HBGap.md(), + Conditional.single( + condition: _categoriesNotifier.value.isNotEmpty, + widget: HBChips( + chips: List.generate(_categoriesNotifier.value.length, (int index) { + final CreateBookCategory category = _categoriesNotifier.value[index]; + return HBChip( + text: category.toOneLine(), + color: HBColors.gray900 + ); + }) + ), + fallback: Text( + 'Keine Kategorien gewählt', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 14.0, + fontWeight: FontWeight.w600 + ) + ) + ) + ] + ) + ) + ] + ), + const HBDialogSection(title: 'Verlagsinformationen'), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: HBDateButton( + onChanged: _handlePublishDateChanged, + title: 'Veröffentlichung', + dateTime: _publishDateNotifier.value + ) + ), + const HBGap.lg(), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Verläge', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 16.0, + fontWeight: FontWeight.w400, + color: HBColors.gray900 + ) + ) + ), + const HBGap.md(), + HBLightButton( + onPressed: _handleEditPublishers, + textAlign: TextAlign.end, + title: 'Bearbeiten' + ) + ] + ), + const HBGap.md(), + Conditional.single( + condition: _publishersNotifier.value.isNotEmpty, + widget: HBChips( + chips: List.generate(_publishersNotifier.value.length, (int index) { + final CreateBookPublisher publisher = _publishersNotifier.value[index]; + return HBChip( + text: publisher.toOneLine(), + color: HBColors.gray900 + ); + }) + ), + fallback: Text( + 'Keine Verläge gewählt', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 14.0, + fontWeight: FontWeight.w600 + ) + ) + ) + ] + ) + ) + ] + ), + const HBDialogSection(title: 'Anlageinformationen'), + Row( + children: [ + Expanded( + child: HBDateButton( + onChanged: _handleReceivedAtDateChanged, + title: 'Erhalten', + dateTime: _receivedAtNotifier.value + ) + ), + const HBGap.lg(), + Expanded( + child: Center( + child: HBCheckbox( + onChanged: _handleBoughtChanged, + text: 'Ist gekauft?', + isSelected: _boughtNotifier.value + ) + ) + ) + ] + ) + ] ); } } \ No newline at end of file diff --git a/flutter/lib/src/features/books/presentation/widgets/book_borrows_table.dart b/flutter/lib/src/features/books/presentation/widgets/book_borrows_table.dart new file mode 100644 index 0000000..0311edd --- /dev/null +++ b/flutter/lib/src/features/books/presentation/widgets/book_borrows_table.dart @@ -0,0 +1,235 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:habib_app/src/features/books/presentation/app/book_details_page_notifier.dart'; +import 'package:habib_app/src/features/borrows/presentation/pages/create_borrow_page.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:habib_app/core/extensions/object_extension.dart'; +import 'package:habib_app/core/common/widgets/hb_button.dart'; +import 'package:habib_app/core/common/widgets/hb_chip.dart'; +import 'package:habib_app/core/common/widgets/hb_gap.dart'; +import 'package:habib_app/core/common/widgets/hb_table.dart'; +import 'package:habib_app/core/common/widgets/hb_text_field.dart'; +import 'package:habib_app/core/extensions/context_extension.dart'; +import 'package:habib_app/core/extensions/datetime_extension.dart'; +import 'package:habib_app/core/res/hb_icons.dart'; +import 'package:habib_app/core/res/theme/colors/hb_colors.dart'; +import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; +import 'package:habib_app/core/res/theme/typography/hb_typography.dart'; +import 'package:habib_app/core/services/routes.dart'; +import 'package:habib_app/core/utils/constants/hb_ui_constants.dart'; +import 'package:habib_app/core/utils/core_utils.dart'; +import 'package:habib_app/core/utils/enums/toast_type.dart'; +import 'package:habib_app/src/features/books/domain/entities/book_borrow_entity.dart'; +import 'package:habib_app/src/features/books/presentation/app/book_borrows_notifier.dart'; + +class BookBorrowsTable extends StatefulHookConsumerWidget { + + final int bookId; + + const BookBorrowsTable({ + super.key, + required this.bookId + }); + + @override + ConsumerState createState() => _BookBorrowsTableState(); +} + +class _BookBorrowsTableState extends ConsumerState { + + final ScrollController _scrollController = ScrollController(); + + late TextEditingController _searchController; + + void _onBorrowsStateUpdate(BookBorrowsState? _, BookBorrowsState next) { + if (next.hasError) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: next.error!.errorTitle, + description: next.error!.errorDescription, + ); + } + } + + Future _onBorrowPressed(int borrowId) async { + await BorrowDetailsRoute(borrowId: borrowId).push(context); + } + + Future _onSearchChanged(String _) async { + await ref.read(bookBorrowsNotifierProvider(widget.bookId).notifier).refresh(_searchText); + } + + Future _onRefreshBorrows() async { + await ref.read(bookBorrowsNotifierProvider(widget.bookId).notifier).refresh(_searchText); + } + + Future _onCustomerPressed(int customerId) async { + await CustomerDetailsRoute(customerId: customerId).push(context); + } + + Future _onNewBorrow() async { + final BookDetailsPageState pageState = ref.read(bookDetailsPageNotifierProvider(widget.bookId)); + if (!pageState.hasBook) return; + final CreateBorrowBook book = CreateBorrowBook( + id: pageState.book!.id, + title: pageState.book!.title, + edition: pageState.book!.edition + ); + final CreateBorrowPageParams params = CreateBorrowPageParams(book: book); + await CreateBorrowRoute($extra: params).push(context); + } + + bool get _isBottom { + if (!_scrollController.hasClients) return false; + final double maxScroll = _scrollController.position.maxScrollExtent; + final double currentScroll = _scrollController.offset; + return currentScroll >= maxScroll * 0.9; + } + + void _onScroll() { + if (_isBottom) { + ref.read(bookBorrowsNotifierProvider(widget.bookId).notifier).fetchNextPage(_searchText); + } + } + + String get _searchText { + return _searchController.text.trim(); + } + + HBTableStatus get _tableStatus { + final BookBorrowsState pageState = ref.read(bookBorrowsNotifierProvider(widget.bookId)); + if (pageState.hasBooksBorrows) return HBTableStatus.data; + if (pageState.hasError || !pageState.hasBooksBorrows) return HBTableStatus.text; + return HBTableStatus.loading; + } + + String? get _tableText { + final BookBorrowsState pageState = ref.read(bookBorrowsNotifierProvider(widget.bookId)); + if (!pageState.isLoading && !pageState.hasError && !pageState.hasBooksBorrows) return 'Keine Ausleihen gefunden.'; + if (pageState.hasError) return 'Ein Fehler ist aufgetreten.'; + return null; + } + + @override + void initState() { + super.initState(); + + _scrollController.addListener(_onScroll); + + CoreUtils.postFrameCall(() { + ref.read(bookBorrowsNotifierProvider(widget.bookId).notifier).fetchNextPage(_searchText); + }); + } + + @override + void dispose() { + _scrollController + ..removeListener(_onScroll) + ..dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + + final BookBorrowsState borrowsState = ref.watch(bookBorrowsNotifierProvider(widget.bookId)); + + _searchController = useTextEditingController(); + + ref.listen( + bookBorrowsNotifierProvider(widget.bookId), + _onBorrowsStateUpdate + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + top: HBSpacing.lg + ), + child: Text( + 'Ausleihen', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontWeight: FontWeight.w600, + fontSize: 20.0, + color: HBColors.gray900 + ) + ) + ), + Padding( + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + top: HBSpacing.lg + ), + child: Row( + children: [ + HBTextField( + controller: _searchController, + onChanged: _onSearchChanged, + icon: HBIcons.magnifyingGlass, + hint: 'Kundenname', + maxWidth: 500.0 + ), + const HBGap.lg(), + const Spacer(), + HBButton.shrinkFill( + onPressed: _onNewBorrow, + icon: HBIcons.plus, + title: 'Neue Ausleihe' + ), + const HBGap.md(), + HBButton.shrinkFill( + onPressed: _onRefreshBorrows, + icon: HBIcons.arrowPath + ) + ] + ) + ), + Expanded( + child: HBTable( + onPressed: (int index) => _onBorrowPressed(borrowsState.bookBorrows[index].id), + status: _tableStatus, + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + bottom: context.bottomPadding + HBSpacing.lg, + top: HBSpacing.xxl + ), + controller: _scrollController, + tableWidth: context.width - HBUIConstants.navigationRailWidth - context.rightPadding - 4.0 * HBSpacing.lg, + columnLength: 3, + fractions: const [ 0.7, 0.15, 0.15 ], + titles: const [ 'Kundenname', 'Rückgabedatum', 'Status' ], + items: List.generate(borrowsState.bookBorrows.length, (int index) { + final BookBorrowEntity borrow = borrowsState.bookBorrows[index]; + return [ + HBTableText( + onPressed: () => _onCustomerPressed(borrow.customer.id), + text: '${ borrow.customer.title != null ? '${ borrow.customer.title } ' : '' }${ borrow.customer.firstName } ${ borrow.customer.lastName }' + ), + HBTableText(text: borrow.endDate.toHumanReadableDate()), + HBTableChip( + chip: HBChip( + text: borrow.status.title, + color: borrow.status.color + ) + ) + ]; + }), + text: _tableText + ) + ) + ] + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/books/presentation/widgets/books_selection_dialog.dart b/flutter/lib/src/features/books/presentation/widgets/books_selection_dialog.dart new file mode 100644 index 0000000..1c11bd9 --- /dev/null +++ b/flutter/lib/src/features/books/presentation/widgets/books_selection_dialog.dart @@ -0,0 +1,239 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:habib_app/core/common/widgets/hb_chip.dart'; +import 'package:habib_app/src/features/books/domain/entities/book_author_entity.dart'; +import 'package:habib_app/src/features/books/domain/entities/book_entity.dart'; +import 'package:habib_app/src/features/books/presentation/app/books_page_notifier.dart'; +import 'package:habib_app/src/features/borrows/presentation/pages/create_borrow_page.dart'; +import 'package:habib_app/core/extensions/object_extension.dart'; +import 'package:habib_app/core/common/widgets/hb_button.dart'; +import 'package:habib_app/core/common/widgets/hb_gap.dart'; +import 'package:habib_app/core/common/widgets/hb_selection_dialog.dart'; +import 'package:habib_app/core/common/widgets/hb_table.dart'; +import 'package:habib_app/core/common/widgets/hb_text_field.dart'; +import 'package:habib_app/core/extensions/context_extension.dart'; +import 'package:habib_app/core/res/hb_icons.dart'; +import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; +import 'package:habib_app/core/utils/core_utils.dart'; +import 'package:habib_app/core/utils/enums/toast_type.dart'; + +Future showBooksSelectionDialog({ + required BuildContext context, + required CreateBorrowBook? book +}) async { + return await showHBSelectionDialog( + context: context, + title: 'Buch wählen', + content: BooksSelectionDialog(book: book) + ); +} + +class BooksSelectionDialog extends StatefulHookConsumerWidget { + + final CreateBorrowBook? book; + + const BooksSelectionDialog({ + super.key, + this.book + }); + + @override + ConsumerState createState() => _BooksSelectionDialogState(); +} + +class _BooksSelectionDialogState extends ConsumerState { + + late ValueNotifier _bookNotifier; + + final ScrollController _scrollController = ScrollController(); + + late TextEditingController _searchController; + + void _onPageStateUpdate(BooksPageState? _, BooksPageState next) { + if (next.hasError) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: next.error!.errorTitle, + description: next.error!.errorDescription, + ); + } + } + + bool get _isBottom { + if (!_scrollController.hasClients) return false; + final double maxScroll = _scrollController.position.maxScrollExtent; + final double currentScroll = _scrollController.offset; + return currentScroll >= maxScroll * 0.9; + } + + void _onScroll() { + if (_isBottom) { + ref.read(booksPageNotifierProvider.notifier).fetchNextPage(_searchText); + } + } + + HBTableStatus get _tableStatus { + final BooksPageState pageState = ref.read(booksPageNotifierProvider); + if (pageState.hasBooks) return HBTableStatus.data; + if (pageState.hasError || !pageState.hasBooks) return HBTableStatus.text; + return HBTableStatus.loading; + } + + String? get _tableText { + final BooksPageState pageState = ref.read(booksPageNotifierProvider); + if (!pageState.isLoading && !pageState.hasError && !pageState.hasBooks) return 'Keine Bücher gefunden.'; + if (pageState.hasError) return 'Ein Fehler ist aufgetreten.'; + return null; + } + + String get _searchText { + return _searchController.text.trim(); + } + + Future _onSearchChanged(String _) async { + await ref.read(booksPageNotifierProvider.notifier).refresh(_searchText); + } + + Future _onRefresh() async { + await ref.read(booksPageNotifierProvider.notifier).refresh(_searchText); + } + + void _onRowPressed(BookEntity selectedBook) async { + final CreateBorrowBook newBook = CreateBorrowBook( + id: selectedBook.id, + title: selectedBook.title, + edition: selectedBook.edition + ); + _bookNotifier.value = newBook; + } + + void _cancel() { + context.pop(); + } + + void _onChoose() { + context.pop(_bookNotifier.value); + } + + @override + void initState() { + super.initState(); + + _scrollController.addListener(_onScroll); + + CoreUtils.postFrameCall(() { + ref.read(booksPageNotifierProvider.notifier).fetchNextPage(_searchText); + }); + } + + @override + void dispose() { + _scrollController + ..removeListener(_onScroll) + ..dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + + _bookNotifier = useState(widget.book); + + final BooksPageState pageState = ref.watch(booksPageNotifierProvider); + + _searchController = useTextEditingController(); + + ref.listen( + booksPageNotifierProvider, + _onPageStateUpdate + ); + + return Column( + children: [ + Padding( + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + top: HBSpacing.lg + ), + child: Row( + children: [ + Expanded( + child: HBTextField( + controller: _searchController, + onChanged: _onSearchChanged, + icon: HBIcons.magnifyingGlass, + hint: 'Buchtitel oder ISBN' + ) + ), + const HBGap.lg(), + HBButton.shrinkFill( + onPressed: _onRefresh, + icon: HBIcons.arrowPath + ) + ] + ) + ), + Expanded( + child: HBTable( + onPressed: (int index) => _onRowPressed(pageState.books[index]), + status: _tableStatus, + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + bottom: context.bottomPadding + HBSpacing.lg, + top: HBSpacing.xxl + ), + controller: _scrollController, + tableWidth: 800.0 - 4.0 * HBSpacing.lg, + columnLength: 6, + fractions: const [ 0.3, 0.15, 0.2, 0.2, 0.1, 0.5 ], + titles: const [ 'Titel (Auflage)', 'Autor', 'ISBN 10', 'ISBN 13', 'Status', '' ], + items: List.generate(pageState.books.length, (int index) { + final BookEntity book = pageState.books[index]; + final bool isSelected = _bookNotifier.value?.id == book.id; + return [ + HBTableText(text: '${ book.title }${ book.edition != null ? ' (${ book.edition }. Auflage)' : '' }'), + HBTableText(text: (book.authors ?? []).map((BookAuthorEntity author) => '${ author.title != null ? '${ author.title } ' : '' }${ author.firstName } ${ author.lastName }').join(', ')), + HBTableText(text: book.isbn10 ?? ''), + HBTableText(text: book.isbn13 ?? ''), + HBTableChip( + chip: HBChip( + text: book.status.title, + color: book.status.color + ) + ), + HBTableRadioIndicator(isSelected: isSelected) + ]; + }), + text: _tableText + ) + ), + const HBGap.lg(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: HBSpacing.lg), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + HBButton.shrinkFill( + onPressed: _onChoose, + title: 'Wählen' + ), + const HBGap.md(), + HBButton.shrinkOutline( + onPressed: _cancel, + title: 'Abbrechen' + ) + ] + ) + ) + ] + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/borrows/data/datasources/borrow_datasource.dart b/flutter/lib/src/features/borrows/data/datasources/borrow_datasource.dart index 7ec25d7..29c0f8f 100644 --- a/flutter/lib/src/features/borrows/data/datasources/borrow_datasource.dart +++ b/flutter/lib/src/features/borrows/data/datasources/borrow_datasource.dart @@ -1,5 +1,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:habib_app/src/features/borrows/data/dto/borrow_details_dto.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; import 'package:habib_app/core/services/database.dart'; import 'package:habib_app/src/features/borrows/data/datasources/borrow_datasource_impl.dart'; import 'package:habib_app/src/features/borrows/data/dto/borrow_dto.dart'; @@ -18,4 +20,17 @@ abstract interface class BorrowDatasource { const BorrowDatasource(); Future> getBorrows({ required String searchText, required int currentPage }); + + Future getBorrow({ required int borrowId }); + + Future createBorrow({ + required Json borrowJson + }); + + Future updateBorrow({ + required int borrowId, + required Json borrowJson + }); + + Future deleteBorrow({ required int borrowId }); } \ No newline at end of file diff --git a/flutter/lib/src/features/borrows/data/datasources/borrow_datasource_impl.dart b/flutter/lib/src/features/borrows/data/datasources/borrow_datasource_impl.dart index d3ef383..db935a1 100644 --- a/flutter/lib/src/features/borrows/data/datasources/borrow_datasource_impl.dart +++ b/flutter/lib/src/features/borrows/data/datasources/borrow_datasource_impl.dart @@ -1,6 +1,7 @@ import 'package:habib_app/core/utils/typedefs.dart'; import 'package:habib_app/core/services/database.dart'; import 'package:habib_app/src/features/borrows/data/datasources/borrow_datasource.dart'; +import 'package:habib_app/src/features/borrows/data/dto/borrow_details_dto.dart'; import 'package:habib_app/src/features/borrows/data/dto/borrow_dto.dart'; class BorrowDatasourceImpl implements BorrowDatasource { @@ -19,4 +20,35 @@ class BorrowDatasourceImpl implements BorrowDatasource { ); return BorrowDto.listFromJsonList(jsonList); } + + @override + Future getBorrow({ required int borrowId }) async { + final Json json = await _database.getBorrow(borrowId: borrowId); + return BorrowDetailsDto.fromJson(json); + } + + @override + Future createBorrow({ + required Json borrowJson + }) async { + return await _database.createBorrow( + borrowJson: borrowJson + ); + } + + @override + Future updateBorrow({ + required int borrowId, + required Json borrowJson + }) async { + return await _database.updateBorrow( + borrowId, + borrowJson + ); + } + + @override + Future deleteBorrow({ required int borrowId }) async { + return await _database.deleteBorrow(borrowId); + } } \ No newline at end of file diff --git a/flutter/lib/src/features/borrows/data/dto/borrow_details_book_dto.dart b/flutter/lib/src/features/borrows/data/dto/borrow_details_book_dto.dart new file mode 100644 index 0000000..7ad8e0f --- /dev/null +++ b/flutter/lib/src/features/borrows/data/dto/borrow_details_book_dto.dart @@ -0,0 +1,19 @@ +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/borrows/domain/entities/borrow_details_book_entity.dart'; + +class BorrowDetailsBookDto extends BorrowDetailsBookEntity { + + const BorrowDetailsBookDto({ + required super.id, + required super.title, + super.edition + }); + + factory BorrowDetailsBookDto.fromJson(Json bookJson) { + return BorrowDetailsBookDto( + id: bookJson['book_id'] as int, + title: bookJson['book_title'] as String, + edition: bookJson['book_edition'] as int? + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/borrows/data/dto/borrow_details_customer_dto.dart b/flutter/lib/src/features/borrows/data/dto/borrow_details_customer_dto.dart new file mode 100644 index 0000000..1b95592 --- /dev/null +++ b/flutter/lib/src/features/borrows/data/dto/borrow_details_customer_dto.dart @@ -0,0 +1,21 @@ +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/borrows/domain/entities/borrow_details_customer_entity.dart'; + +class BorrowDetailsCustomerDto extends BorrowDetailsCustomerEntity { + + const BorrowDetailsCustomerDto({ + required super.id, + super.title, + required super.firstName, + required super.lastName + }); + + factory BorrowDetailsCustomerDto.fromJson(Json customerJson) { + return BorrowDetailsCustomerDto( + id: customerJson['customer_id'] as int, + title: customerJson['customer_title'] as String?, + firstName: customerJson['customer_first_name'] as String, + lastName: customerJson['customer_last_name'] as String + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/borrows/data/dto/borrow_details_dto.dart b/flutter/lib/src/features/borrows/data/dto/borrow_details_dto.dart new file mode 100644 index 0000000..fd5d0a0 --- /dev/null +++ b/flutter/lib/src/features/borrows/data/dto/borrow_details_dto.dart @@ -0,0 +1,32 @@ +import 'dart:convert'; + +import 'package:habib_app/core/utils/enums/borrow_status.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/borrows/data/dto/borrow_details_book_dto.dart'; +import 'package:habib_app/src/features/borrows/data/dto/borrow_details_customer_dto.dart'; +import 'package:habib_app/src/features/borrows/domain/entities/borrow_details_entity.dart'; + +class BorrowDetailsDto extends BorrowDetailsEntity { + + const BorrowDetailsDto({ + required super.id, + required super.book, + required super.customer, + required super.endDate, + required super.status + }); + + factory BorrowDetailsDto.fromJson(Json borrowJson) { + return BorrowDetailsDto( + id: borrowJson['borrow_id'] as int, + book: BorrowDetailsBookDto.fromJson(json.decode(borrowJson['book'] as String)), + customer: BorrowDetailsCustomerDto.fromJson(json.decode(borrowJson['customer'] as String)), + endDate: borrowJson['borrow_end_date'] as DateTime, + status: BorrowStatus.fromDatabaseValue(borrowJson['borrow_status'] as String) + ); + } + + static List listFromJsonList(List jsonList) { + return jsonList.map((Json json) => BorrowDetailsDto.fromJson(json)).toList(); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/borrows/data/repositories/borrow_repository_impl.dart b/flutter/lib/src/features/borrows/data/repositories/borrow_repository_impl.dart index 23ea11d..9b1909b 100644 --- a/flutter/lib/src/features/borrows/data/repositories/borrow_repository_impl.dart +++ b/flutter/lib/src/features/borrows/data/repositories/borrow_repository_impl.dart @@ -1,7 +1,9 @@ import 'package:habib_app/core/utils/result.dart'; import 'package:habib_app/core/utils/typedefs.dart'; import 'package:habib_app/src/features/borrows/data/datasources/borrow_datasource.dart'; +import 'package:habib_app/src/features/borrows/data/dto/borrow_details_dto.dart'; import 'package:habib_app/src/features/borrows/data/dto/borrow_dto.dart'; +import 'package:habib_app/src/features/borrows/domain/entities/borrow_details_entity.dart'; import 'package:habib_app/src/features/borrows/domain/entities/borrow_entity.dart'; import 'package:habib_app/src/features/borrows/domain/repositories/borrow_repository.dart'; @@ -21,7 +23,57 @@ class BorrowRepositoryImpl implements BorrowRepository { currentPage: currentPage ); return Success(result); - } on Exception catch (e) { + } catch (e) { + return Failure(e); + } + } + + @override + ResultFuture getBorrow({ required int borrowId }) async { + try { + final BorrowDetailsDto result = await _borrowDatasource.getBorrow(borrowId: borrowId); + return Success(result); + } catch (e) { + return Failure(e); + } + } + + @override + ResultFuture createBorrow({ + required Json borrowJson + }) async { + try { + final int borrowId = await _borrowDatasource.createBorrow( + borrowJson: borrowJson + ); + return Success(borrowId); + } catch (e) { + return Failure(e); + } + } + + @override + ResultFuture updateBorrow({ + required int borrowId, + required Json borrowJson + }) async { + try { + await _borrowDatasource.updateBorrow( + borrowId: borrowId, + borrowJson: borrowJson + ); + return const Success(null); + } catch (e) { + return Failure(e); + } + } + + @override + ResultFuture deleteBorrow({ required int borrowId }) async { + try { + await _borrowDatasource.deleteBorrow(borrowId: borrowId); + return const Success(null); + } catch (e) { return Failure(e); } } diff --git a/flutter/lib/src/features/borrows/domain/entities/borrow_details_book_entity.dart b/flutter/lib/src/features/borrows/domain/entities/borrow_details_book_entity.dart new file mode 100644 index 0000000..5ce55c0 --- /dev/null +++ b/flutter/lib/src/features/borrows/domain/entities/borrow_details_book_entity.dart @@ -0,0 +1,21 @@ +import 'package:equatable/equatable.dart'; + +class BorrowDetailsBookEntity extends Equatable { + + final int id; + final String title; + final int? edition; + + const BorrowDetailsBookEntity({ + required this.id, + required this.title, + this.edition + }); + + @override + List get props => [ + id, + title, + edition + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/borrows/domain/entities/borrow_details_customer_entity.dart b/flutter/lib/src/features/borrows/domain/entities/borrow_details_customer_entity.dart new file mode 100644 index 0000000..b78da9c --- /dev/null +++ b/flutter/lib/src/features/borrows/domain/entities/borrow_details_customer_entity.dart @@ -0,0 +1,24 @@ +import 'package:equatable/equatable.dart'; + +class BorrowDetailsCustomerEntity extends Equatable { + + final int id; + final String? title; + final String firstName; + final String lastName; + + const BorrowDetailsCustomerEntity({ + required this.id, + this.title, + required this.firstName, + required this.lastName + }); + + @override + List get props => [ + id, + title, + firstName, + lastName + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/borrows/domain/entities/borrow_details_entity.dart b/flutter/lib/src/features/borrows/domain/entities/borrow_details_entity.dart new file mode 100644 index 0000000..bfff0ce --- /dev/null +++ b/flutter/lib/src/features/borrows/domain/entities/borrow_details_entity.dart @@ -0,0 +1,31 @@ +import 'package:equatable/equatable.dart'; + +import 'package:habib_app/core/utils/enums/borrow_status.dart'; +import 'package:habib_app/src/features/borrows/domain/entities/borrow_details_book_entity.dart'; +import 'package:habib_app/src/features/borrows/domain/entities/borrow_details_customer_entity.dart'; + +class BorrowDetailsEntity extends Equatable { + + final int id; + final BorrowDetailsBookEntity book; + final BorrowDetailsCustomerEntity customer; + final DateTime endDate; + final BorrowStatus status; + + const BorrowDetailsEntity({ + required this.id, + required this.book, + required this.customer, + required this.endDate, + required this.status + }); + + @override + List get props => [ + id, + book, + customer, + endDate, + status + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/borrows/domain/repositories/borrow_repository.dart b/flutter/lib/src/features/borrows/domain/repositories/borrow_repository.dart index 12e721e..63de4c3 100644 --- a/flutter/lib/src/features/borrows/domain/repositories/borrow_repository.dart +++ b/flutter/lib/src/features/borrows/domain/repositories/borrow_repository.dart @@ -1,5 +1,6 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:habib_app/src/features/borrows/domain/entities/borrow_details_entity.dart'; import 'package:habib_app/src/features/borrows/domain/entities/borrow_entity.dart'; import 'package:habib_app/src/features/borrows/data/repositories/borrow_repository_impl.dart'; import 'package:habib_app/src/features/borrows/data/datasources/borrow_datasource.dart'; @@ -17,4 +18,17 @@ BorrowRepository borrowRepository(BorrowRepositoryRef ref) { abstract interface class BorrowRepository { ResultFuture> getBorrows({ required String searchText, required int currentPage }); + + ResultFuture getBorrow({ required int borrowId }); + + ResultFuture createBorrow({ + required Json borrowJson + }); + + ResultFuture updateBorrow({ + required int borrowId, + required Json borrowJson + }); + + ResultFuture deleteBorrow({ required int borrowId }); } \ No newline at end of file diff --git a/flutter/lib/src/features/borrows/domain/usecases/borrow_create_borrow_usecase.dart b/flutter/lib/src/features/borrows/domain/usecases/borrow_create_borrow_usecase.dart new file mode 100644 index 0000000..7803702 --- /dev/null +++ b/flutter/lib/src/features/borrows/domain/usecases/borrow_create_borrow_usecase.dart @@ -0,0 +1,45 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/borrows/domain/repositories/borrow_repository.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'borrow_create_borrow_usecase.g.dart'; + +@riverpod +BorrowCreateBorrowUsecase borrowCreateBorrowUsecase(BorrowCreateBorrowUsecaseRef ref) { + return BorrowCreateBorrowUsecase( + borrowRepository: ref.read(borrowRepositoryProvider) + ); +} + +class BorrowCreateBorrowUsecase extends UsecaseWithParams { + + final BorrowRepository _borrowRepository; + + const BorrowCreateBorrowUsecase({ + required BorrowRepository borrowRepository + }) : _borrowRepository = borrowRepository; + + @override + ResultFuture call(BorrowCreateBorrowUsecaseParams params) async { + return await _borrowRepository.createBorrow( + borrowJson: params.borrowJson + ); + } +} + +class BorrowCreateBorrowUsecaseParams extends Equatable { + + final Json borrowJson; + + const BorrowCreateBorrowUsecaseParams({ + required this.borrowJson + }); + + @override + List get props => [ + borrowJson + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/borrows/domain/usecases/borrow_delete_borrow_usecase.dart b/flutter/lib/src/features/borrows/domain/usecases/borrow_delete_borrow_usecase.dart new file mode 100644 index 0000000..9419bcb --- /dev/null +++ b/flutter/lib/src/features/borrows/domain/usecases/borrow_delete_borrow_usecase.dart @@ -0,0 +1,41 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/borrows/domain/repositories/borrow_repository.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'borrow_delete_borrow_usecase.g.dart'; + +@riverpod +BorrowDeleteBorrowUsecase borrowDeleteBorrowUsecase(BorrowDeleteBorrowUsecaseRef ref) { + return BorrowDeleteBorrowUsecase( + borrowRepository: ref.read(borrowRepositoryProvider) + ); +} + +class BorrowDeleteBorrowUsecase extends UsecaseWithParams { + + final BorrowRepository _borrowRepository; + + const BorrowDeleteBorrowUsecase({ + required BorrowRepository borrowRepository + }) : _borrowRepository = borrowRepository; + + @override + ResultFuture call(BorrowDeleteBorrowUsecaseParams params) async { + return await _borrowRepository.deleteBorrow(borrowId: params.borrowId); + } +} + +class BorrowDeleteBorrowUsecaseParams extends Equatable { + + final int borrowId; + + const BorrowDeleteBorrowUsecaseParams({ required this.borrowId }); + + @override + List get props => [ + borrowId + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/borrows/domain/usecases/borrow_get_borrow_usecase.dart b/flutter/lib/src/features/borrows/domain/usecases/borrow_get_borrow_usecase.dart new file mode 100644 index 0000000..58b3898 --- /dev/null +++ b/flutter/lib/src/features/borrows/domain/usecases/borrow_get_borrow_usecase.dart @@ -0,0 +1,43 @@ +import 'package:equatable/equatable.dart'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/borrows/domain/entities/borrow_details_entity.dart'; +import 'package:habib_app/src/features/borrows/domain/repositories/borrow_repository.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'borrow_get_borrow_usecase.g.dart'; + +@riverpod +BorrowGetBorrowUsecase borrowGetBorrowUsecase(BorrowGetBorrowUsecaseRef ref) { + return BorrowGetBorrowUsecase( + borrowRepository: ref.read(borrowRepositoryProvider) + ); +} + +class BorrowGetBorrowUsecase extends UsecaseWithParams { + + final BorrowRepository _borrowRepository; + + const BorrowGetBorrowUsecase({ + required BorrowRepository borrowRepository + }) : _borrowRepository = borrowRepository; + + @override + ResultFuture call(BorrowGetBorrowUsecaseParams params) async { + return await _borrowRepository.getBorrow(borrowId: params.borrowId); + } +} + +class BorrowGetBorrowUsecaseParams extends Equatable { + + final int borrowId; + + const BorrowGetBorrowUsecaseParams({ required this.borrowId }); + + @override + List get props => [ + borrowId + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/borrows/domain/usecases/borrow_update_borrow_usecase.dart b/flutter/lib/src/features/borrows/domain/usecases/borrow_update_borrow_usecase.dart new file mode 100644 index 0000000..20138f3 --- /dev/null +++ b/flutter/lib/src/features/borrows/domain/usecases/borrow_update_borrow_usecase.dart @@ -0,0 +1,49 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/borrows/domain/repositories/borrow_repository.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'borrow_update_borrow_usecase.g.dart'; + +@riverpod +BorrowUpdateBorrowUsecase borrowUpdateBorrowUsecase(BorrowUpdateBorrowUsecaseRef ref) { + return BorrowUpdateBorrowUsecase( + borrowRepository: ref.read(borrowRepositoryProvider) + ); +} + +class BorrowUpdateBorrowUsecase extends UsecaseWithParams { + + final BorrowRepository _borrowRepository; + + const BorrowUpdateBorrowUsecase({ + required BorrowRepository borrowRepository + }) : _borrowRepository = borrowRepository; + + @override + ResultFuture call(BorrowUpdateBorrowUsecaseParams params) async { + return await _borrowRepository.updateBorrow( + borrowId: params.borrowId, + borrowJson: params.borrowJson + ); + } +} + +class BorrowUpdateBorrowUsecaseParams extends Equatable { + + final int borrowId; + final Json borrowJson; + + const BorrowUpdateBorrowUsecaseParams({ + required this.borrowId, + required this.borrowJson + }); + + @override + List get props => [ + borrowId, + borrowJson + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/borrows/presentation/app/borrow_details_page_notifier.dart b/flutter/lib/src/features/borrows/presentation/app/borrow_details_page_notifier.dart new file mode 100644 index 0000000..84fbe54 --- /dev/null +++ b/flutter/lib/src/features/borrows/presentation/app/borrow_details_page_notifier.dart @@ -0,0 +1,93 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/core/common/models/error_details.dart'; +import 'package:habib_app/src/features/borrows/domain/entities/borrow_details_entity.dart'; +import 'package:habib_app/src/features/borrows/domain/usecases/borrow_get_borrow_usecase.dart'; +import 'package:habib_app/core/utils/result.dart'; + +part 'borrow_details_page_notifier.g.dart'; + +@riverpod +class BorrowDetailsPageNotifier extends _$BorrowDetailsPageNotifier { + + late BorrowGetBorrowUsecase _borrowGetBorrowUsecase; + + @override + BorrowDetailsPageState build(int borrowId) { + _borrowGetBorrowUsecase = ref.read(borrowGetBorrowUsecaseProvider); + return const BorrowDetailsPageState(); + } + + void replace(BorrowDetailsEntity borrow) { + state = state.copyWith(borrow: borrow); + } + + Future fetch() async { + if (state.isLoading) return; + + state = state.copyWith( + isBorrowLoading: true, + removeError: true + ); + + final BorrowGetBorrowUsecaseParams borrowParams = BorrowGetBorrowUsecaseParams(borrowId: borrowId); + final Result result = await _borrowGetBorrowUsecase.call(borrowParams); + + result.fold( + onSuccess: (BorrowDetailsEntity borrow) { + state = state.copyWith( + isBorrowLoading: false, + borrow: borrow + ); + }, + onFailure: (Object error, StackTrace stackTrace) { + state = state.copyWith( + isBorrowLoading: false, + error: ErrorDetails( + error: error, + stackTrace: stackTrace + ) + ); + } + ); + } +} + +class BorrowDetailsPageState extends Equatable { + + final bool isBorrowLoading; + final ErrorDetails? error; + final BorrowDetailsEntity? borrow; + + const BorrowDetailsPageState({ + this.isBorrowLoading = false, + this.error, + this.borrow + }); + + bool get hasError => error != null; + bool get isLoading => isBorrowLoading; + bool get hasBorrow => borrow != null; + + BorrowDetailsPageState copyWith({ + bool? isBorrowLoading = false, + ErrorDetails? error, + BorrowDetailsEntity? borrow, + bool removeError = false, + bool removeBorrow = false + }) { + return BorrowDetailsPageState( + isBorrowLoading: isBorrowLoading ?? this.isBorrowLoading, + error: removeError ? null : error ?? this.error, + borrow: removeBorrow ? null : borrow ?? this.borrow + ); + } + + @override + List get props => [ + isBorrowLoading, + error, + borrow + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/borrows/presentation/app/borrows_page_notifier.dart b/flutter/lib/src/features/borrows/presentation/app/borrows_page_notifier.dart index 8c669fe..7ca6077 100644 --- a/flutter/lib/src/features/borrows/presentation/app/borrows_page_notifier.dart +++ b/flutter/lib/src/features/borrows/presentation/app/borrows_page_notifier.dart @@ -1,64 +1,14 @@ import 'package:equatable/equatable.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:habib_app/core/common/models/error_details.dart'; +import 'package:habib_app/src/features/borrows/domain/entities/borrow_entity.dart'; import 'package:habib_app/src/features/borrows/domain/usecases/borrow_get_borrows_usecase.dart'; import 'package:habib_app/core/utils/constants/network_constants.dart'; import 'package:habib_app/core/utils/result.dart'; -import 'package:habib_app/src/features/borrows/domain/entities/borrow_entity.dart'; part 'borrows_page_notifier.g.dart'; -enum BorrowsPageStatus { - initial, - loading, - success, - failure -} - -class BorrowsPageState extends Equatable { - - final BorrowsPageStatus status; - final Exception? exception; - final List borrows; - final bool hasReachedEnd; - final int currentPage; - - const BorrowsPageState({ - this.status = BorrowsPageStatus.initial, - this.exception, - this.borrows = const [], - this.hasReachedEnd = false, - this.currentPage = 1 - }); - - BorrowsPageState copyWith({ - BorrowsPageStatus? status, - Exception? exception, - List? borrows, - bool? hasReachedEnd, - int? currentPage, - bool removeException = false, - bool removeBorrows = false - }) { - return BorrowsPageState( - status: status ?? this.status, - exception: removeException ? null : exception ?? this.exception, - borrows: removeBorrows ? [] : borrows ?? this.borrows, - hasReachedEnd: hasReachedEnd ?? this.hasReachedEnd, - currentPage: currentPage ?? this.currentPage - ); - } - - @override - List get props => [ - status, - exception, - borrows, - hasReachedEnd, - currentPage - ]; -} - @riverpod class BorrowsPageNotifier extends _$BorrowsPageNotifier { @@ -71,12 +21,11 @@ class BorrowsPageNotifier extends _$BorrowsPageNotifier { } Future fetchNextPage(String searchText) async { - if (state.status == BorrowsPageStatus.loading) return; - if (state.hasReachedEnd) return; - + if (state.isLoading || state.hasReachedEnd) return; + state = state.copyWith( - status: BorrowsPageStatus.loading, - removeException: true + isBorrowsLoading: true, + removeError: true ); final BorrowGetBorrowsUsecaseParams params = BorrowGetBorrowsUsecaseParams( @@ -88,26 +37,76 @@ class BorrowsPageNotifier extends _$BorrowsPageNotifier { result.fold( onSuccess: (List borrows) { state = state.copyWith( - status: BorrowsPageStatus.success, + isBorrowsLoading: false, currentPage: borrows.isEmpty ? state.currentPage : state.currentPage + 1, borrows: List.of(state.borrows)..addAll(borrows), - hasReachedEnd: borrows.length < NetworkConstants.pageSize, - removeException: true + hasReachedEnd: borrows.length < NetworkConstants.pageSize ); - }, - onFailure: (Exception exception, StackTrace stackTrace) { + }, + onFailure: (Object error, StackTrace stackTrace) { state = state.copyWith( - status: BorrowsPageStatus.failure, - exception: exception + isBorrowsLoading: false, + error: ErrorDetails( + error: error, + stackTrace: stackTrace + ) ); } ); } - + Future refresh(String searchText) async { state = const BorrowsPageState(); await fetchNextPage(searchText); } +} + +class BorrowsPageState extends Equatable { + + final bool isBorrowsLoading; + final ErrorDetails? error; + final List borrows; + final bool hasReachedEnd; + final int currentPage; + + const BorrowsPageState({ + this.isBorrowsLoading = false, + this.error, + this.borrows = const [], + this.hasReachedEnd = false, + this.currentPage = 1 + }); + + bool get hasError => error != null; + bool get isLoading => isBorrowsLoading; + bool get hasBorrows => borrows.isNotEmpty; + + BorrowsPageState copyWith({ + bool? isBorrowsLoading = false, + ErrorDetails? error, + List? borrows, + bool? hasReachedEnd, + int? currentPage, + bool removeError = false, + bool removeBorrows = false + }) { + return BorrowsPageState( + isBorrowsLoading: isBorrowsLoading ?? this.isBorrowsLoading, + error: removeError ? null : error ?? this.error, + borrows: removeBorrows ? const [] : borrows ?? this.borrows, + hasReachedEnd: hasReachedEnd ?? this.hasReachedEnd, + currentPage: currentPage ?? this.currentPage + ); + } + + @override + List get props => [ + isBorrowsLoading, + error, + borrows, + hasReachedEnd, + currentPage + ]; } \ No newline at end of file diff --git a/flutter/lib/src/features/borrows/presentation/pages/borrow_details_page.dart b/flutter/lib/src/features/borrows/presentation/pages/borrow_details_page.dart index b34bfc1..b35638a 100644 --- a/flutter/lib/src/features/borrows/presentation/pages/borrow_details_page.dart +++ b/flutter/lib/src/features/borrows/presentation/pages/borrow_details_page.dart @@ -1,7 +1,39 @@ import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:habib_app/src/features/books/presentation/widgets/books_selection_dialog.dart'; +import 'package:habib_app/src/features/borrows/presentation/pages/create_borrow_page.dart'; +import 'package:habib_app/src/features/borrows/presentation/widgets/borrow_status_selection_dialog.dart'; +import 'package:habib_app/src/features/customers/presentation/widgets/customers_selection_dialog.dart'; +import 'package:habib_app/core/common/widgets/hb_chip.dart'; +import 'package:habib_app/core/common/widgets/hb_date_button.dart'; +import 'package:habib_app/core/common/widgets/hb_light_button.dart'; +import 'package:habib_app/core/common/widgets/hb_gap.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/core/utils/validator.dart'; +import 'package:habib_app/src/features/borrows/domain/entities/borrow_details_entity.dart'; +import 'package:habib_app/src/features/borrows/domain/usecases/borrow_update_borrow_usecase.dart'; +import 'package:habib_app/core/utils/enums/borrow_status.dart'; +import 'package:habib_app/src/features/borrows/domain/entities/borrow_details_book_entity.dart'; +import 'package:habib_app/src/features/borrows/domain/entities/borrow_details_customer_entity.dart'; +import 'package:habib_app/src/features/borrows/domain/usecases/borrow_delete_borrow_usecase.dart'; +import 'package:habib_app/src/features/borrows/presentation/app/borrow_details_page_notifier.dart'; +import 'package:habib_app/core/res/theme/colors/hb_colors.dart'; +import 'package:habib_app/core/res/theme/typography/hb_typography.dart'; +import 'package:habib_app/core/extensions/context_extension.dart'; +import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; +import 'package:habib_app/core/common/models/error_details.dart'; +import 'package:habib_app/core/common/widgets/hb_message_box.dart'; +import 'package:habib_app/core/utils/result.dart'; +import 'package:habib_app/core/extensions/object_extension.dart'; +import 'package:habib_app/core/utils/enums/toast_type.dart'; import 'package:habib_app/core/common/widgets/hb_app_bar.dart'; import 'package:habib_app/core/common/widgets/hb_scaffold.dart'; +import 'package:habib_app/core/res/hb_icons.dart'; +import 'package:habib_app/core/utils/core_utils.dart'; class BorrowDetailsPageParams { @@ -12,7 +44,7 @@ class BorrowDetailsPageParams { }); } -class BorrowDetailsPage extends StatelessWidget { +class BorrowDetailsPage extends StatefulHookConsumerWidget { final BorrowDetailsPageParams params; @@ -21,14 +53,419 @@ class BorrowDetailsPage extends StatelessWidget { required this.params }); + @override + ConsumerState createState() => _BorrowDetailsPageState(); +} + +class _BorrowDetailsPageState extends ConsumerState { + + late ValueNotifier _endDateNotifier; + late ValueNotifier _statusNotifier; + late ValueNotifier _bookNotifier; + late ValueNotifier _customerNotifier; + + void _onDetailsStateUpdate(BorrowDetailsPageState? _, BorrowDetailsPageState next) { + if (next.hasError) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: next.error!.errorTitle, + description: next.error!.errorDescription, + ); + } + } + + Future _onRefresh() async { + ref.read(borrowDetailsPageNotifierProvider(widget.params.borrowId).notifier).fetch(); + } + + Future _onSave() async { + final BorrowDetailsPageState pageState = ref.watch(borrowDetailsPageNotifierProvider(widget.params.borrowId)); + final BorrowDetailsEntity borrow = pageState.borrow!; + + final DateTime endDate = _endDateNotifier.value; + final BorrowStatus status = _statusNotifier.value; + final BorrowDetailsBookEntity book = _bookNotifier.value; + final BorrowDetailsCustomerEntity customer = _customerNotifier.value; + + try { + Validator.validateBorrowUpdate( + endDate: endDate, + status: status + ); + } catch (e) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: e.errorTitle, + description: e.errorDescription, + ); + return; + } + + final bool replaceEndDate = endDate != borrow.endDate; + final bool replaceStatus = status != borrow.status; + final bool replaceBook = book.id != borrow.book.id; + final bool replaceCustomer = customer.id != borrow.customer.id; + + Json borrowJson = { + if (replaceEndDate) 'end_date' : "'${ endDate.toIso8601String() }'", + if (replaceStatus) 'status' : "'${ status.databaseValue }'", + if (replaceBook) 'book_id' : book.id, + if (replaceCustomer) 'customer_id' : customer.id, + }; + + if (borrowJson.isEmpty) return; + + final BorrowUpdateBorrowUsecase borrowUpdateBorrowUsecase = ref.read(borrowUpdateBorrowUsecaseProvider); + final BorrowUpdateBorrowUsecaseParams borrowUpdateBorrowUsecaseParams = BorrowUpdateBorrowUsecaseParams( + borrowId: borrow.id, + borrowJson: borrowJson + ); + final Result borrowUpdateBorrowUsecaseResult = await borrowUpdateBorrowUsecase.call(borrowUpdateBorrowUsecaseParams); + + borrowUpdateBorrowUsecaseResult.fold( + onSuccess: (void _) { + ref.read(borrowDetailsPageNotifierProvider(widget.params.borrowId).notifier).replace( + BorrowDetailsEntity( + id: customer.id, + endDate: replaceEndDate ? endDate : borrow.endDate, + status: replaceStatus ? status : borrow.status, + customer: replaceCustomer ? customer : borrow.customer, + book: replaceBook ? book : borrow.book + ) + ); + + CoreUtils.showToast( + context, + type: ToastType.success, + title: 'Erfolgreich aktualisiert.', + description: 'Die Ausleihe wurde erfolgreich aktualisiert.' + ); + }, + onFailure: (Object error, StackTrace stackTrace) { + final ErrorDetails errorDetails = ErrorDetails( + error: error, + stackTrace: stackTrace + ); + CoreUtils.showToast( + context, + type: ToastType.error, + title: errorDetails.errorTitle, + description: errorDetails.errorDescription, + ); + } + ); + } + + Future _onDelete() async { + final bool? success = await showHBMessageBox( + context, + 'Diese Ausleihe wirklich löschen?', + 'Wenn Sie diese Ausleihe löschen, werden alle damit verbundenen Daten ebenfalls gelöscht. Dieser Vorgang kann nicht rückgängig gemacht werden.', + 'Löschen', + onPressed: () async { + final BorrowDeleteBorrowUsecase borrowDeleteBorrowUsecase = ref.read(borrowDeleteBorrowUsecaseProvider); + final BorrowDeleteBorrowUsecaseParams borrowDeleteBorrowUsecaseParams = BorrowDeleteBorrowUsecaseParams(borrowId: widget.params.borrowId); + final Result borrowDeleteBorrowUsecaseResult = await borrowDeleteBorrowUsecase.call(borrowDeleteBorrowUsecaseParams); + + return borrowDeleteBorrowUsecaseResult.fold( + onSuccess: (void _) => true, + onFailure: (Object error, StackTrace stackTrace) { + final ErrorDetails errorDetails = ErrorDetails( + error: error, + stackTrace: stackTrace + ); + CoreUtils.showToast( + context, + type: ToastType.error, + title: errorDetails.errorTitle, + description: errorDetails.errorDescription, + ); + return false; + } + ); + } + ); + + if (mounted && context.canPop() && (success ?? false)) context.pop(true); + } + + void _handleEndDateChanged(DateTime newDate) { + _endDateNotifier.value = newDate; + } + + Future _handleEditStatus() async { + BorrowStatus? newStatus = await showBorrowStatusSelectionDialog(context: context); + if (newStatus != null) _statusNotifier.value = newStatus; + } + + Future _handleEditBook() async { + CreateBorrowBook? newBook = await showBooksSelectionDialog( + context: context, + book: CreateBorrowBook( + id: _bookNotifier.value.id, + title: _bookNotifier.value.title, + edition: _bookNotifier.value.edition + ) + ); + + if (newBook != null) { + _bookNotifier.value = BorrowDetailsBookEntity( + id: newBook.id, + title: newBook.title, + edition: newBook.edition + ); + } + } + + Future _handleEditCustomer() async { + CreateBorrowCustomer? newCustomer = await showCustomersSelectionDialog( + context: context, + customer: CreateBorrowCustomer( + id: _customerNotifier.value.id, + title: _customerNotifier.value.title, + firstName: _customerNotifier.value.firstName, + lastName: _customerNotifier.value.lastName + ) + ); + + if (newCustomer != null) { + _customerNotifier.value = BorrowDetailsCustomerEntity( + id: newCustomer.id, + title: newCustomer.title, + firstName: newCustomer.firstName, + lastName: newCustomer.lastName + ); + } + } + + @override + void initState() { + super.initState(); + + CoreUtils.postFrameCall(() { + ref.read(borrowDetailsPageNotifierProvider(widget.params.borrowId).notifier).fetch(); + }); + } + @override Widget build(BuildContext context) { + + final BorrowDetailsPageState pageState = ref.watch(borrowDetailsPageNotifierProvider(widget.params.borrowId)); + + if (pageState.hasBorrow) { + _endDateNotifier = useState(pageState.borrow!.endDate); + _statusNotifier = useState(pageState.borrow!.status); + _bookNotifier = useState(pageState.borrow!.book); + _customerNotifier = useState(pageState.borrow!.customer); + } + + ref.listen( + borrowDetailsPageNotifierProvider(widget.params.borrowId), + _onDetailsStateUpdate + ); + return HBScaffold( appBar: HBAppBar( context: context, title: 'Details', - backButton: const HBAppBarBackButton() - ) + backButton: const HBAppBarBackButton(), + actionButtons: [ + HBAppBarButton( + onPressed: _onRefresh, + icon: HBIcons.arrowPath, + isEnabled: !pageState.isLoading + ), + HBAppBarButton( + onPressed: _onSave, + icon: HBIcons.cloudArrowUp, + isEnabled: pageState.hasBorrow + ), + HBAppBarButton( + onPressed: _onDelete, + icon: HBIcons.trash, + isEnabled: pageState.hasBorrow + ) + ] + ), + body: pageState.hasBorrow + ? SingleChildScrollView( + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + top: HBSpacing.lg + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Allgemeine Details', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 20.0, + fontWeight: FontWeight.w600, + color: HBColors.gray900 + ) + ), + const HBGap.xl(), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: HBDateButton( + onChanged: _handleEndDateChanged, + title: 'Rückgabedatum', + dateTime: _endDateNotifier.value + ) + ), + const HBGap.xl(), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Status', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 16.0, + fontWeight: FontWeight.w400, + color: HBColors.gray900 + ) + ) + ), + const HBGap.md(), + HBLightButton( + onPressed: _handleEditStatus, + isEnabled: pageState.hasBorrow, + textAlign: TextAlign.end, + title: 'Bearbeiten' + ) + ] + ), + const HBGap.md(), + HBChip( + text: _statusNotifier.value.title, + color: _statusNotifier.value.color + ) + ] + ) + ), + const HBGap.xl(), + const Spacer() + ] + ), + const HBGap.xxl(), + Text( + 'Leihinformationen', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 20.0, + fontWeight: FontWeight.w600, + color: HBColors.gray900 + ) + ), + const HBGap.xl(), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Buch', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 16.0, + fontWeight: FontWeight.w400, + color: HBColors.gray900 + ) + ) + ), + const HBGap.md(), + HBLightButton( + onPressed: _handleEditBook, + isEnabled: pageState.hasBorrow, + textAlign: TextAlign.end, + title: 'Bearbeiten' + ) + ] + ), + const HBGap.md(), + HBChip( + text: '${ _bookNotifier.value.title }${ _bookNotifier.value.edition != null ? ' (${ _bookNotifier.value.edition }. Auflage)' : '' }', + color: HBColors.gray900 + ) + ] + ) + ), + const HBGap.xl(), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Kunde', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 16.0, + fontWeight: FontWeight.w400, + color: HBColors.gray900 + ) + ) + ), + const HBGap.md(), + HBLightButton( + onPressed: _handleEditCustomer, + isEnabled: pageState.hasBorrow, + textAlign: TextAlign.end, + title: 'Bearbeiten' + ) + ] + ), + const HBGap.md(), + HBChip( + text: '${ _customerNotifier.value.title != null ? '${ _customerNotifier.value.title } ' : '' }${ _customerNotifier.value.firstName } ${ _customerNotifier.value.lastName }', + color: HBColors.gray900 + ) + ] + ) + ), + const HBGap.xl(), + const Spacer() + ] + ) + ] + ) + ) + : pageState.hasError + ? Center( + child: Text( + pageState.error!.errorDescription, + textAlign: TextAlign.center, + style: HBTypography.base.copyWith( + fontSize: 14.0, + fontWeight: FontWeight.w600, + color: HBColors.gray900 + ), + ) + ) + : const SizedBox.shrink() ); } } \ No newline at end of file diff --git a/flutter/lib/src/features/borrows/presentation/pages/borrows_page.dart b/flutter/lib/src/features/borrows/presentation/pages/borrows_page.dart index e3c314f..64dfc59 100644 --- a/flutter/lib/src/features/borrows/presentation/pages/borrows_page.dart +++ b/flutter/lib/src/features/borrows/presentation/pages/borrows_page.dart @@ -3,16 +3,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:habib_app/core/common/widgets/hb_chip.dart'; import 'package:habib_app/core/extensions/datetime_extension.dart'; +import 'package:habib_app/core/extensions/object_extension.dart'; +import 'package:habib_app/core/common/widgets/hb_chip.dart'; import 'package:habib_app/core/common/widgets/hb_table.dart'; -import 'package:habib_app/core/extensions/exception_extension.dart'; import 'package:habib_app/core/utils/enums/toast_type.dart'; import 'package:habib_app/core/common/widgets/hb_app_bar.dart'; import 'package:habib_app/core/common/widgets/hb_button.dart'; import 'package:habib_app/core/common/widgets/hb_gap.dart'; import 'package:habib_app/core/common/widgets/hb_scaffold.dart'; -import 'package:habib_app/core/common/widgets/sc_text_field.dart'; +import 'package:habib_app/core/common/widgets/hb_text_field.dart'; import 'package:habib_app/core/extensions/context_extension.dart'; import 'package:habib_app/core/res/hb_icons.dart'; import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; @@ -37,12 +37,12 @@ class _BorrowsPageState extends ConsumerState { late TextEditingController _searchController; void _onPageStateUpdate(BorrowsPageState? _, BorrowsPageState next) { - if (next.exception != null) { + if (next.hasError) { CoreUtils.showToast( context, type: ToastType.error, - title: next.exception!.title(context), - description: next.exception!.description(context) + title: next.error!.errorTitle, + description: next.error!.errorDescription, ); } } @@ -62,15 +62,15 @@ class _BorrowsPageState extends ConsumerState { HBTableStatus get _tableStatus { final BorrowsPageState pageState = ref.read(borrowsPageNotifierProvider); - if (pageState.status == BorrowsPageStatus.success && pageState.borrows.isNotEmpty) return HBTableStatus.data; - if (pageState.status == BorrowsPageStatus.failure || (pageState.status == BorrowsPageStatus.success && pageState.borrows.isEmpty)) return HBTableStatus.text; + if (pageState.hasBorrows) return HBTableStatus.data; + if (pageState.hasError || !pageState.hasBorrows) return HBTableStatus.text; return HBTableStatus.loading; } String? get _tableText { final BorrowsPageState pageState = ref.read(borrowsPageNotifierProvider); - if (pageState.status == BorrowsPageStatus.success && pageState.borrows.isEmpty) return 'Keine Ausleihen gefunden.'; - if (pageState.status == BorrowsPageStatus.failure) return 'Ein Fehler ist aufgetreten.'; + if (!pageState.isLoading && !pageState.hasError && !pageState.hasBorrows) return 'Keine Ausleihen gefunden.'; + if (pageState.hasError) return 'Ein Fehler ist aufgetreten.'; return null; } @@ -86,7 +86,7 @@ class _BorrowsPageState extends ConsumerState { await const CreateBorrowRoute().push(context); } - Future _onSearchChanged() async { + Future _onSearchChanged(String _) async { await ref.read(borrowsPageNotifierProvider.notifier).refresh(_searchText); } @@ -94,6 +94,15 @@ class _BorrowsPageState extends ConsumerState { await ref.read(borrowsPageNotifierProvider.notifier).refresh(_searchText); } + Future _onCustomerPressed(int customerId) async { + final bool? customerDeleted = await CustomerDetailsRoute(customerId: customerId).push(context); + if (customerDeleted ?? false) ref.read(borrowsPageNotifierProvider.notifier).refresh(_searchText); + } + + Future _onBookPressed(int bookId) async { + await BookDetailsRoute(bookId: bookId).push(context); + } + @override void initState() { super.initState(); @@ -143,7 +152,7 @@ class _BorrowsPageState extends ConsumerState { children: [ HBTextField( controller: _searchController, - onChanged: (String _) => _onSearchChanged, + onChanged: _onSearchChanged, icon: HBIcons.magnifyingGlass, hint: 'Buchtitel oder Kundenname', maxWidth: 500.0 @@ -181,9 +190,15 @@ class _BorrowsPageState extends ConsumerState { items: List.generate(pageState.borrows.length, (int index) { final BorrowEntity borrow = pageState.borrows[index]; return [ - HBTableText(text: '${ borrow.customer.title != null ? '${ borrow.customer.title } ' : '' }${ borrow.customer.firstName } ${ borrow.customer.lastName }'), - HBTableText(text: '${ borrow.book.title }${ borrow.book.edition != null ? ' (${ borrow.book.edition }. Auflage)' : '' }'), - HBTableText(text: borrow.endDate.toHumanReadable()), + HBTableText( + onPressed: () => _onCustomerPressed(borrow.customer.id), + text: '${ borrow.customer.title != null ? '${ borrow.customer.title } ' : '' }${ borrow.customer.firstName } ${ borrow.customer.lastName }' + ), + HBTableText( + onPressed: () => _onBookPressed(borrow.book.id), + text: '${ borrow.book.title }${ borrow.book.edition != null ? ' (${ borrow.book.edition }. Auflage)' : '' }' + ), + HBTableText(text: borrow.endDate.toHumanReadableDate()), HBTableChip( chip: HBChip( text: borrow.status.title, diff --git a/flutter/lib/src/features/borrows/presentation/pages/create_borrow_page.dart b/flutter/lib/src/features/borrows/presentation/pages/create_borrow_page.dart index 6c74fa3..250b17f 100644 --- a/flutter/lib/src/features/borrows/presentation/pages/create_borrow_page.dart +++ b/flutter/lib/src/features/borrows/presentation/pages/create_borrow_page.dart @@ -1,28 +1,355 @@ import 'package:flutter/material.dart'; +import 'package:flutter_conditional/flutter_conditional.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:habib_app/core/common/widgets/hb_chip.dart'; +import 'package:habib_app/src/features/borrows/presentation/widgets/borrow_status_selection_dialog.dart'; +import 'package:habib_app/src/features/books/presentation/widgets/books_selection_dialog.dart'; +import 'package:habib_app/src/features/customers/presentation/widgets/customers_selection_dialog.dart'; +import 'package:habib_app/core/common/models/error_details.dart'; +import 'package:habib_app/core/extensions/object_extension.dart'; +import 'package:habib_app/core/services/routes.dart'; +import 'package:habib_app/core/utils/core_utils.dart'; +import 'package:habib_app/core/utils/enums/toast_type.dart'; +import 'package:habib_app/core/utils/result.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/core/utils/validator.dart'; +import 'package:habib_app/src/features/borrows/domain/usecases/borrow_create_borrow_usecase.dart'; +import 'package:habib_app/core/utils/enums/borrow_status.dart'; +import 'package:habib_app/core/common/widgets/hb_light_button.dart'; +import 'package:habib_app/core/res/theme/colors/hb_colors.dart'; +import 'package:habib_app/core/res/theme/typography/hb_typography.dart'; +import 'package:habib_app/core/common/widgets/hb_date_button.dart'; import 'package:habib_app/core/common/widgets/hb_dialog.dart'; +import 'package:habib_app/core/common/widgets/hb_gap.dart'; -class CreateBorrowPage extends StatefulHookWidget { +class CreateBorrowPageParams { + + final CreateBorrowBook? book; + final CreateBorrowCustomer? customer; + + const CreateBorrowPageParams({ + this.book, + this.customer + }); +} + +class CreateBorrowBook { + + final int id; + final String title; + final int? edition; + + const CreateBorrowBook({ + required this.id, + required this.title, + this.edition + }); + + String toOneLine() { + return '$title${ edition != null ? ' ($edition. Auflage)' : '' }'; + } +} + +class CreateBorrowCustomer { + + final int id; + final String? title; + final String firstName; + final String lastName; + + const CreateBorrowCustomer({ + required this.id, + this.title, + required this.firstName, + required this.lastName + }); + + String toOneLine() { + return '${ title != null ? '$title ' : '' }$firstName $lastName'; + } +} + +class CreateBorrowPage extends StatefulHookConsumerWidget { + + final CreateBorrowPageParams? params; - const CreateBorrowPage({ super.key }); + const CreateBorrowPage({ + super.key, + this.params + }); @override - State createState() => _CreateBorrowPageState(); + ConsumerState createState() => _CreateBorrowPageState(); } -class _CreateBorrowPageState extends State { +class _CreateBorrowPageState extends ConsumerState { + + late ValueNotifier _endDateNotifier; + late ValueNotifier _statusNotifier; + late ValueNotifier _bookNotifier; + late ValueNotifier _customerNotifier; + + Future _handleCreate() async { + final DateTime endDate = _endDateNotifier.value; + final BorrowStatus status = _statusNotifier.value; + final int? bookId = _bookNotifier.value?.id; + final int? customerId = _customerNotifier.value?.id; + + try { + Validator.validateBorrowCreate( + endDate: endDate, + status: status + ); + } catch (e) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: e.errorTitle, + description: e.errorDescription, + ); + return; + } + + Json borrowJson = { + 'end_date' : "'${ endDate.toIso8601String() }'", + 'status' : "'${ status.databaseValue }'", + 'book_id' : bookId, + 'customer_id' : customerId + }; + + final BorrowCreateBorrowUsecase borrowCreateBorrowUsecase = ref.read(borrowCreateBorrowUsecaseProvider); + final BorrowCreateBorrowUsecaseParams borrowCreateBorrowUsecaseParams = BorrowCreateBorrowUsecaseParams( + borrowJson: borrowJson + ); + final Result borrowCreateBorrowUsecaseResult = await borrowCreateBorrowUsecase.call(borrowCreateBorrowUsecaseParams); + + borrowCreateBorrowUsecaseResult.fold( + onSuccess: (int borrowId) async { + CoreUtils.showToast( + context, + type: ToastType.success, + title: 'Erfolgreich angelegt.', + description: 'Die Ausleihe wurde erfolgreich angelegt.' + ); + + if (!mounted) return; + + context.pop(); + + await BorrowDetailsRoute(borrowId: borrowId).push(context); + }, + onFailure: (Object error, StackTrace stackTrace) { + final ErrorDetails errorDetails = ErrorDetails( + error: error, + stackTrace: stackTrace + ); + CoreUtils.showToast( + context, + type: ToastType.error, + title: errorDetails.errorTitle, + description: errorDetails.errorDescription, + ); + } + ); + } + + void _handleEndDateChanged(DateTime newDate) { + _endDateNotifier.value = newDate; + } + + Future _handleEditStatus() async { + BorrowStatus? newStatus = await showBorrowStatusSelectionDialog(context: context); + if (newStatus != null) _statusNotifier.value = newStatus; + } + + Future _handleEditBook() async { + CreateBorrowBook? newBook = await showBooksSelectionDialog( + context: context, + book: _bookNotifier.value + ); + + if (newBook != null) _bookNotifier.value = newBook; + } + + Future _handleEditCustomer() async { + CreateBorrowCustomer? newCustomer = await showCustomersSelectionDialog( + context: context, + customer: _customerNotifier.value + ); + + if (newCustomer != null) _customerNotifier.value = newCustomer; + } @override Widget build(BuildContext context) { + _endDateNotifier = useState(DateTime.now().add(const Duration(days: 14))); + _statusNotifier = useState(BorrowStatus.borrowed); + _bookNotifier = useState(widget.params?.book); + _customerNotifier = useState(widget.params?.customer); + return HBDialog( title: 'Neue Ausleihe', actionButton: HBDialogActionButton( - onPressed: () {}, + onPressed: _handleCreate, title: 'Erstellen' - ) + ), + children: [ + const HBDialogSection( + title: 'Allgemeine Details', + isFirstSection: true + ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: HBDateButton( + onChanged: _handleEndDateChanged, + title: 'Rückgabedatum', + dateTime: _endDateNotifier.value + ) + ), + const HBGap.lg(), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Status', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 16.0, + fontWeight: FontWeight.w400, + color: HBColors.gray900 + ) + ) + ), + const HBGap.md(), + HBLightButton( + onPressed: _handleEditStatus, + textAlign: TextAlign.end, + title: 'Bearbeiten' + ) + ] + ), + const HBGap.md(), + HBChip( + text: _statusNotifier.value.title, + color: _statusNotifier.value.color + ) + ] + ) + ) + ] + ), + const HBDialogSection(title: 'Leihinformationen'), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Buch', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 16.0, + fontWeight: FontWeight.w400, + color: HBColors.gray900 + ) + ) + ), + const HBGap.md(), + HBLightButton( + onPressed: _handleEditBook, + textAlign: TextAlign.end, + title: 'Bearbeiten' + ) + ] + ), + const HBGap.md(), + Conditional.single( + condition: _bookNotifier.value != null, + widget: HBChip( + text: '${ _bookNotifier.value?.title }${ _bookNotifier.value?.edition != null ? ' (${ _bookNotifier.value?.edition }. Auflage)' : '' }', + color: HBColors.gray900 + ), + fallback: Text( + 'Kein Buch gewählt', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 14.0, + fontWeight: FontWeight.w600 + ) + ) + ) + ] + ) + ), + const HBGap.lg(), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Kunde', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 16.0, + fontWeight: FontWeight.w400, + color: HBColors.gray900 + ) + ) + ), + const HBGap.md(), + HBLightButton( + onPressed: _handleEditCustomer, + textAlign: TextAlign.end, + title: 'Bearbeiten' + ) + ] + ), + const HBGap.md(), + Conditional.single( + condition: _customerNotifier.value != null, + widget: HBChip( + text: '${ _customerNotifier.value?.title != null ? '${ _customerNotifier.value?.title } ' : '' }${ _customerNotifier.value?.firstName } ${ _customerNotifier.value?.lastName }', + color: HBColors.gray900 + ), + fallback: Text( + 'Kein Kunde gewählt', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 14.0, + fontWeight: FontWeight.w600 + ) + ) + ) + ] + ) + ) + ] + ) + ] ); } } \ No newline at end of file diff --git a/flutter/lib/src/features/borrows/presentation/widgets/borrow_status_selection_dialog.dart b/flutter/lib/src/features/borrows/presentation/widgets/borrow_status_selection_dialog.dart new file mode 100644 index 0000000..b3c01ff --- /dev/null +++ b/flutter/lib/src/features/borrows/presentation/widgets/borrow_status_selection_dialog.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; + +import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; +import 'package:habib_app/core/utils/enums/borrow_status.dart'; +import 'package:habib_app/core/common/widgets/hb_chip.dart'; +import 'package:habib_app/core/common/widgets/hb_selection_dialog.dart'; + +Future showBorrowStatusSelectionDialog({ + required BuildContext context +}) async { + return await showHBSelectionDialog( + context: context, + title: 'Status wählen', + content: const BorrowStatusSelectionDialog() + ); +} + +class BorrowStatusSelectionDialog extends StatelessWidget { + + const BorrowStatusSelectionDialog({ super.key }); + + void _onStatusPressed(BuildContext context, BorrowStatus status) async { + context.pop(status); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: HBSpacing.lg), + child: HBChips( + chips: List.generate(BorrowStatus.values.length, (int index) { + final BorrowStatus status = BorrowStatus.values[index]; + return HBChip( + onPressed: () => _onStatusPressed(context, status), + text: status.title, + color: status.color + ); + }) + ) + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/categories/data/datasources/category_datasource.dart b/flutter/lib/src/features/categories/data/datasources/category_datasource.dart new file mode 100644 index 0000000..9ae0567 --- /dev/null +++ b/flutter/lib/src/features/categories/data/datasources/category_datasource.dart @@ -0,0 +1,36 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/categories/data/dto/category_details_dto.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/categories/data/dto/category_dto.dart'; +import 'package:habib_app/src/features/categories/data/datasources/category_datasource_impl.dart'; +import 'package:habib_app/core/services/database.dart'; + +part 'category_datasource.g.dart'; + +@riverpod +CategoryDatasource categoryDatasource(CategoryDatasourceRef ref) { + return CategoryDatasourceImpl( + database: ref.read(databaseProvider) + ); +} + +abstract interface class CategoryDatasource { + + const CategoryDatasource(); + + Future> getCategories({ required String searchText, required int currentPage }); + + Future createCategory({ + required Json categoryJson + }); + + Future getCategory({ required int categoryId }); + + Future updateCategory({ + required int categoryId, + required Json categoryJson + }); + + Future deleteCategory({ required int categoryId }); +} \ No newline at end of file diff --git a/flutter/lib/src/features/categories/data/datasources/category_datasource_impl.dart b/flutter/lib/src/features/categories/data/datasources/category_datasource_impl.dart new file mode 100644 index 0000000..a1cb52c --- /dev/null +++ b/flutter/lib/src/features/categories/data/datasources/category_datasource_impl.dart @@ -0,0 +1,52 @@ +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/core/services/database.dart'; +import 'package:habib_app/src/features/categories/data/datasources/category_datasource.dart'; +import 'package:habib_app/src/features/categories/data/dto/category_details_dto.dart'; +import 'package:habib_app/src/features/categories/data/dto/category_dto.dart'; + +class CategoryDatasourceImpl implements CategoryDatasource { + + final Database _database; + + const CategoryDatasourceImpl({ + required Database database + }) : _database = database; + + @override + Future> getCategories({ required String searchText, required int currentPage }) async { + final List jsonList = await _database.getCategories( + searchText: searchText, + currentPage: currentPage + ); + return CategoryDto.listFromJsonList(jsonList); + } + + @override + Future createCategory({ + required Json categoryJson + }) async { + return await _database.createCategory(categoryJson: categoryJson); + } + + @override + Future getCategory({ required int categoryId }) async { + final Json json = await _database.getCategory(categoryId: categoryId); + return CategoryDetailsDto.fromJson(json); + } + + @override + Future updateCategory({ + required int categoryId, + required Json categoryJson + }) async { + return await _database.updateCategory( + categoryId, + categoryJson + ); + } + + @override + Future deleteCategory({ required int categoryId }) async { + return await _database.deleteCategory(categoryId); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/categories/data/dto/category_details_dto.dart b/flutter/lib/src/features/categories/data/dto/category_details_dto.dart new file mode 100644 index 0000000..836f070 --- /dev/null +++ b/flutter/lib/src/features/categories/data/dto/category_details_dto.dart @@ -0,0 +1,17 @@ +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/categories/domain/entities/category_details_entity.dart'; + +class CategoryDetailsDto extends CategoryDetailsEntity { + + const CategoryDetailsDto({ + required super.id, + required super.name + }); + + factory CategoryDetailsDto.fromJson(Json categoryJson) { + return CategoryDetailsDto( + id: categoryJson['category_id'] as int, + name: categoryJson['category_name'] as String + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/categories/data/dto/category_dto.dart b/flutter/lib/src/features/categories/data/dto/category_dto.dart new file mode 100644 index 0000000..f092c68 --- /dev/null +++ b/flutter/lib/src/features/categories/data/dto/category_dto.dart @@ -0,0 +1,21 @@ +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/categories/domain/entities/category_entity.dart'; + +class CategoryDto extends CategoryEntity { + + const CategoryDto({ + required super.id, + required super.name + }); + + factory CategoryDto.fromJson(Json categoryJson) { + return CategoryDto( + id: categoryJson['category_id'] as int, + name: categoryJson['category_name'] as String + ); + } + + static List listFromJsonList(List jsonList) { + return jsonList.map((Json json) => CategoryDto.fromJson(json)).toList(); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/categories/data/repositories/category_repository_impl.dart b/flutter/lib/src/features/categories/data/repositories/category_repository_impl.dart new file mode 100644 index 0000000..8f23400 --- /dev/null +++ b/flutter/lib/src/features/categories/data/repositories/category_repository_impl.dart @@ -0,0 +1,78 @@ +import 'package:habib_app/core/utils/result.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/categories/data/datasources/category_datasource.dart'; +import 'package:habib_app/src/features/categories/data/dto/category_details_dto.dart'; +import 'package:habib_app/src/features/categories/data/dto/category_dto.dart'; +import 'package:habib_app/src/features/categories/domain/entities/category_details_entity.dart'; +import 'package:habib_app/src/features/categories/domain/entities/category_entity.dart'; +import 'package:habib_app/src/features/categories/domain/repositories/category_repository.dart'; + +class CategoryRepositoryImpl implements CategoryRepository { + + final CategoryDatasource _categoryDatasource; + + const CategoryRepositoryImpl({ + required CategoryDatasource categoryDatasource + }) : _categoryDatasource = categoryDatasource; + + @override + ResultFuture> getCategories({ required String searchText, required int currentPage }) async { + try { + final List result = await _categoryDatasource.getCategories( + searchText: searchText, + currentPage: currentPage + ); + return Success(result); + } catch (e) { + return Failure(e); + } + } + + @override + ResultFuture createCategory({ + required Json categoryJson + }) async { + try { + final int categoryId = await _categoryDatasource.createCategory(categoryJson: categoryJson); + return Success(categoryId); + } catch (e) { + return Failure(e); + } + } + + @override + ResultFuture getCategory({ required int categoryId }) async { + try { + final CategoryDetailsDto result = await _categoryDatasource.getCategory(categoryId: categoryId); + return Success(result); + } catch (e) { + return Failure(e); + } + } + + @override + ResultFuture updateCategory({ + required int categoryId, + required Json categoryJson + }) async { + try { + await _categoryDatasource.updateCategory( + categoryId: categoryId, + categoryJson: categoryJson + ); + return const Success(null); + } catch (e) { + return Failure(e); + } + } + + @override + ResultFuture deleteCategory({ required int categoryId }) async { + try { + await _categoryDatasource.deleteCategory(categoryId: categoryId); + return const Success(null); + } catch (e) { + return Failure(e); + } + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/categories/domain/entities/category_details_entity.dart b/flutter/lib/src/features/categories/domain/entities/category_details_entity.dart new file mode 100644 index 0000000..b64453d --- /dev/null +++ b/flutter/lib/src/features/categories/domain/entities/category_details_entity.dart @@ -0,0 +1,18 @@ +import 'package:equatable/equatable.dart'; + +class CategoryDetailsEntity extends Equatable { + + final int id; + final String name; + + const CategoryDetailsEntity({ + required this.id, + required this.name + }); + + @override + List get props => [ + id, + name + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/books/domain/entities/category_entity.dart b/flutter/lib/src/features/categories/domain/entities/category_entity.dart similarity index 100% rename from flutter/lib/src/features/books/domain/entities/category_entity.dart rename to flutter/lib/src/features/categories/domain/entities/category_entity.dart diff --git a/flutter/lib/src/features/categories/domain/repositories/category_repository.dart b/flutter/lib/src/features/categories/domain/repositories/category_repository.dart new file mode 100644 index 0000000..c4159ca --- /dev/null +++ b/flutter/lib/src/features/categories/domain/repositories/category_repository.dart @@ -0,0 +1,34 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/categories/domain/entities/category_details_entity.dart'; +import 'package:habib_app/src/features/categories/data/datasources/category_datasource.dart'; +import 'package:habib_app/src/features/categories/data/repositories/category_repository_impl.dart'; +import 'package:habib_app/src/features/categories/domain/entities/category_entity.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'category_repository.g.dart'; + +@riverpod +CategoryRepository categoryRepository(CategoryRepositoryRef ref) { + return CategoryRepositoryImpl( + categoryDatasource: ref.read(categoryDatasourceProvider) + ); +} + +abstract interface class CategoryRepository { + + ResultFuture> getCategories({ required String searchText, required int currentPage }); + + ResultFuture createCategory({ + required Json categoryJson + }); + + ResultFuture getCategory({ required int categoryId }); + + ResultFuture updateCategory({ + required int categoryId, + required Json categoryJson + }); + + ResultFuture deleteCategory({ required int categoryId }); +} \ No newline at end of file diff --git a/flutter/lib/src/features/categories/domain/usecases/category_create_category_usecase.dart b/flutter/lib/src/features/categories/domain/usecases/category_create_category_usecase.dart new file mode 100644 index 0000000..8045482 --- /dev/null +++ b/flutter/lib/src/features/categories/domain/usecases/category_create_category_usecase.dart @@ -0,0 +1,43 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/categories/domain/repositories/category_repository.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'category_create_category_usecase.g.dart'; + +@riverpod +CategoryCreateCategoryUsecase categoryCreateCategoryUsecase(CategoryCreateCategoryUsecaseRef ref) { + return CategoryCreateCategoryUsecase( + categoryRepository: ref.read(categoryRepositoryProvider) + ); +} + +class CategoryCreateCategoryUsecase extends UsecaseWithParams { + + final CategoryRepository _categoryRepository; + + const CategoryCreateCategoryUsecase({ + required CategoryRepository categoryRepository + }) : _categoryRepository = categoryRepository; + + @override + ResultFuture call(CategoryCreateCategoryUsecaseParams params) async { + return await _categoryRepository.createCategory(categoryJson: params.categoryJson); + } +} + +class CategoryCreateCategoryUsecaseParams extends Equatable { + + final Json categoryJson; + + const CategoryCreateCategoryUsecaseParams({ + required this.categoryJson + }); + + @override + List get props => [ + categoryJson + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/categories/domain/usecases/category_delete_category_usecase.dart b/flutter/lib/src/features/categories/domain/usecases/category_delete_category_usecase.dart new file mode 100644 index 0000000..e868c01 --- /dev/null +++ b/flutter/lib/src/features/categories/domain/usecases/category_delete_category_usecase.dart @@ -0,0 +1,41 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/categories/domain/repositories/category_repository.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'category_delete_category_usecase.g.dart'; + +@riverpod +CategoryDeleteCategoryUsecase categoryDeleteCategoryUsecase(CategoryDeleteCategoryUsecaseRef ref) { + return CategoryDeleteCategoryUsecase( + categoryRepository: ref.read(categoryRepositoryProvider) + ); +} + +class CategoryDeleteCategoryUsecase extends UsecaseWithParams { + + final CategoryRepository _categoryRepository; + + const CategoryDeleteCategoryUsecase({ + required CategoryRepository categoryRepository + }) : _categoryRepository = categoryRepository; + + @override + ResultFuture call(CategoryDeleteCategoryUsecaseParams params) async { + return await _categoryRepository.deleteCategory(categoryId: params.categoryId); + } +} + +class CategoryDeleteCategoryUsecaseParams extends Equatable { + + final int categoryId; + + const CategoryDeleteCategoryUsecaseParams({ required this.categoryId }); + + @override + List get props => [ + categoryId + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/categories/domain/usecases/category_get_categories_usecase.dart b/flutter/lib/src/features/categories/domain/usecases/category_get_categories_usecase.dart new file mode 100644 index 0000000..76fa8bf --- /dev/null +++ b/flutter/lib/src/features/categories/domain/usecases/category_get_categories_usecase.dart @@ -0,0 +1,50 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/categories/domain/entities/category_entity.dart'; +import 'package:habib_app/src/features/categories/domain/repositories/category_repository.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'category_get_categories_usecase.g.dart'; + +@riverpod +CategoryGetCategoriesUsecase categoryGetCategoriesUsecase(CategoryGetCategoriesUsecaseRef ref) { + return CategoryGetCategoriesUsecase( + categoryRepository: ref.read(categoryRepositoryProvider) + ); +} + +class CategoryGetCategoriesUsecase extends UsecaseWithParams, CategoryGetCategoriesUsecaseParams> { + + final CategoryRepository _categoryRepository; + + const CategoryGetCategoriesUsecase({ + required CategoryRepository categoryRepository + }) : _categoryRepository = categoryRepository; + + @override + ResultFuture> call(CategoryGetCategoriesUsecaseParams params) async { + return await _categoryRepository.getCategories( + searchText: params.searchText, + currentPage: params.currentPage + ); + } +} + +class CategoryGetCategoriesUsecaseParams extends Equatable { + + final String searchText; + final int currentPage; + + const CategoryGetCategoriesUsecaseParams({ + required this.searchText, + required this.currentPage + }); + + @override + List get props => [ + searchText, + currentPage + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/categories/presentation/app/categories_page_notifier.dart b/flutter/lib/src/features/categories/presentation/app/categories_page_notifier.dart new file mode 100644 index 0000000..8178328 --- /dev/null +++ b/flutter/lib/src/features/categories/presentation/app/categories_page_notifier.dart @@ -0,0 +1,112 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/core/common/models/error_details.dart'; +import 'package:habib_app/src/features/categories/domain/entities/category_entity.dart'; +import 'package:habib_app/src/features/categories/domain/usecases/category_get_categories_usecase.dart'; +import 'package:habib_app/core/utils/constants/network_constants.dart'; +import 'package:habib_app/core/utils/result.dart'; + +part 'categories_page_notifier.g.dart'; + +@riverpod +class CategoriesPageNotifier extends _$CategoriesPageNotifier { + + late CategoryGetCategoriesUsecase _categoryGetCategoriesUsecase; + + @override + CategoriesPageState build() { + _categoryGetCategoriesUsecase = ref.read(categoryGetCategoriesUsecaseProvider); + return const CategoriesPageState(); + } + + Future fetchNextPage(String searchText) async { + if (state.isLoading || state.hasReachedEnd) return; + + state = state.copyWith( + isCategoriesLoading: true, + removeError: true + ); + + final CategoryGetCategoriesUsecaseParams params = CategoryGetCategoriesUsecaseParams( + searchText: searchText, + currentPage: state.currentPage + ); + final Result> result = await _categoryGetCategoriesUsecase.call(params); + + result.fold( + onSuccess: (List categories) { + state = state.copyWith( + isCategoriesLoading: false, + currentPage: categories.isEmpty + ? state.currentPage + : state.currentPage + 1, + categories: List.of(state.categories)..addAll(categories), + hasReachedEnd: categories.length < NetworkConstants.pageSize + ); + }, + onFailure: (Object error, StackTrace stackTrace) { + state = state.copyWith( + isCategoriesLoading: false, + error: ErrorDetails( + error: error, + stackTrace: stackTrace + ) + ); + } + ); + } + + Future refresh(String searchText) async { + state = const CategoriesPageState(); + await fetchNextPage(searchText); + } +} + +class CategoriesPageState extends Equatable { + + final bool isCategoriesLoading; + final ErrorDetails? error; + final List categories; + final bool hasReachedEnd; + final int currentPage; + + const CategoriesPageState({ + this.isCategoriesLoading = false, + this.error, + this.categories = const [], + this.hasReachedEnd = false, + this.currentPage = 1 + }); + + bool get hasError => error != null; + bool get isLoading => isCategoriesLoading; + bool get hasCategories => categories.isNotEmpty; + + CategoriesPageState copyWith({ + bool? isCategoriesLoading = false, + ErrorDetails? error, + List? categories, + bool? hasReachedEnd, + int? currentPage, + bool removeError = false, + bool removeCategories = false + }) { + return CategoriesPageState( + isCategoriesLoading: isCategoriesLoading ?? this.isCategoriesLoading, + error: removeError ? null : error ?? this.error, + categories: removeCategories ? const [] : categories ?? this.categories, + hasReachedEnd: hasReachedEnd ?? this.hasReachedEnd, + currentPage: currentPage ?? this.currentPage + ); + } + + @override + List get props => [ + isCategoriesLoading, + error, + categories, + hasReachedEnd, + currentPage + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/categories/presentation/app/category_details_page_notifier.dart b/flutter/lib/src/features/categories/presentation/app/category_details_page_notifier.dart new file mode 100644 index 0000000..271f66d --- /dev/null +++ b/flutter/lib/src/features/categories/presentation/app/category_details_page_notifier.dart @@ -0,0 +1,93 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/categories/domain/entities/category_details_entity.dart'; +import 'package:habib_app/src/features/categories/presentation/app/category_get_category_usecase.dart'; +import 'package:habib_app/core/common/models/error_details.dart'; +import 'package:habib_app/core/utils/result.dart'; + +part 'category_details_page_notifier.g.dart'; + +@riverpod +class CategoryDetailsPageNotifier extends _$CategoryDetailsPageNotifier { + + late CategoryGetCategoryUsecase _categoryGetCategoryUsecase; + + @override + CategoryDetailsPageState build(int categoryId) { + _categoryGetCategoryUsecase = ref.read(categoryGetCategoryUsecaseProvider); + return const CategoryDetailsPageState(); + } + + void replace(CategoryDetailsEntity category) { + state = state.copyWith(category: category); + } + + Future fetch() async { + if (state.isLoading) return; + + state = state.copyWith( + isCategoryLoading: true, + removeError: true + ); + + final CategoryGetCategoryUsecaseParams categoryParams = CategoryGetCategoryUsecaseParams(categoryId: categoryId); + final Result result = await _categoryGetCategoryUsecase.call(categoryParams); + + result.fold( + onSuccess: (CategoryDetailsEntity category) { + state = state.copyWith( + isCategoryLoading: false, + category: category + ); + }, + onFailure: (Object error, StackTrace stackTrace) { + state = state.copyWith( + isCategoryLoading: false, + error: ErrorDetails( + error: error, + stackTrace: stackTrace + ) + ); + } + ); + } +} + +class CategoryDetailsPageState extends Equatable { + + final bool isCategoryLoading; + final ErrorDetails? error; + final CategoryDetailsEntity? category; + + const CategoryDetailsPageState({ + this.isCategoryLoading = false, + this.error, + this.category + }); + + bool get hasError => error != null; + bool get isLoading => isCategoryLoading; + bool get hasCategory => category != null; + + CategoryDetailsPageState copyWith({ + bool? isCategoryLoading = false, + ErrorDetails? error, + CategoryDetailsEntity? category, + bool removeError = false, + bool removeCategory = false + }) { + return CategoryDetailsPageState( + isCategoryLoading: isCategoryLoading ?? this.isCategoryLoading, + error: removeError ? null : error ?? this.error, + category: removeCategory ? null : category ?? this.category + ); + } + + @override + List get props => [ + isCategoryLoading, + error, + category + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/categories/presentation/app/category_get_category_usecase.dart b/flutter/lib/src/features/categories/presentation/app/category_get_category_usecase.dart new file mode 100644 index 0000000..447e35b --- /dev/null +++ b/flutter/lib/src/features/categories/presentation/app/category_get_category_usecase.dart @@ -0,0 +1,42 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/categories/domain/entities/category_details_entity.dart'; +import 'package:habib_app/src/features/categories/domain/repositories/category_repository.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'category_get_category_usecase.g.dart'; + +@riverpod +CategoryGetCategoryUsecase categoryGetCategoryUsecase(CategoryGetCategoryUsecaseRef ref) { + return CategoryGetCategoryUsecase( + categoryRepository: ref.read(categoryRepositoryProvider) + ); +} + +class CategoryGetCategoryUsecase extends UsecaseWithParams { + + final CategoryRepository _categoryRepository; + + const CategoryGetCategoryUsecase({ + required CategoryRepository categoryRepository + }) : _categoryRepository = categoryRepository; + + @override + ResultFuture call(CategoryGetCategoryUsecaseParams params) async { + return await _categoryRepository.getCategory(categoryId: params.categoryId); + } +} + +class CategoryGetCategoryUsecaseParams extends Equatable { + + final int categoryId; + + const CategoryGetCategoryUsecaseParams({ required this.categoryId }); + + @override + List get props => [ + categoryId + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/categories/presentation/app/category_update_category_usecase.dart b/flutter/lib/src/features/categories/presentation/app/category_update_category_usecase.dart new file mode 100644 index 0000000..63b564b --- /dev/null +++ b/flutter/lib/src/features/categories/presentation/app/category_update_category_usecase.dart @@ -0,0 +1,49 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/categories/domain/repositories/category_repository.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'category_update_category_usecase.g.dart'; + +@riverpod +CategoryUpdateCategoryUsecase categoryUpdateCategoryUsecase(CategoryUpdateCategoryUsecaseRef ref) { + return CategoryUpdateCategoryUsecase( + categoryRepository: ref.read(categoryRepositoryProvider) + ); +} + +class CategoryUpdateCategoryUsecase extends UsecaseWithParams { + + final CategoryRepository _categoryRepository; + + const CategoryUpdateCategoryUsecase({ + required CategoryRepository categoryRepository + }) : _categoryRepository = categoryRepository; + + @override + ResultFuture call(CategoryUpdateCategoryUsecaseParams params) async { + return await _categoryRepository.updateCategory( + categoryId: params.categoryId, + categoryJson: params.categoryJson + ); + } +} + +class CategoryUpdateCategoryUsecaseParams extends Equatable { + + final int categoryId; + final Json categoryJson; + + const CategoryUpdateCategoryUsecaseParams({ + required this.categoryId, + required this.categoryJson + }); + + @override + List get props => [ + categoryId, + categoryJson + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/categories/presentation/pages/categories_page.dart b/flutter/lib/src/features/categories/presentation/pages/categories_page.dart new file mode 100644 index 0000000..be60f54 --- /dev/null +++ b/flutter/lib/src/features/categories/presentation/pages/categories_page.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:habib_app/core/extensions/object_extension.dart'; +import 'package:habib_app/core/services/routes.dart'; +import 'package:habib_app/src/features/categories/presentation/app/categories_page_notifier.dart'; +import 'package:habib_app/core/common/widgets/hb_table.dart'; +import 'package:habib_app/src/features/categories/domain/entities/category_entity.dart'; +import 'package:habib_app/core/common/widgets/hb_app_bar.dart'; +import 'package:habib_app/core/common/widgets/hb_button.dart'; +import 'package:habib_app/core/common/widgets/hb_gap.dart'; +import 'package:habib_app/core/common/widgets/hb_scaffold.dart'; +import 'package:habib_app/core/common/widgets/hb_text_field.dart'; +import 'package:habib_app/core/extensions/context_extension.dart'; +import 'package:habib_app/core/res/hb_icons.dart'; +import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; +import 'package:habib_app/core/utils/constants/hb_ui_constants.dart'; +import 'package:habib_app/core/utils/core_utils.dart'; +import 'package:habib_app/core/utils/enums/toast_type.dart'; + +class CategoriesPage extends StatefulHookConsumerWidget { + + const CategoriesPage({ super.key }); + + @override + ConsumerState createState() => _CategoriesPageState(); +} + +class _CategoriesPageState extends ConsumerState { + + final ScrollController _scrollController = ScrollController(); + + late TextEditingController _searchController; + + void _onPageStateUpdate(CategoriesPageState? _, CategoriesPageState next) { + if (next.hasError) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: next.error!.errorTitle, + description: next.error!.errorDescription, + ); + } + } + + bool get _isBottom { + if (!_scrollController.hasClients) return false; + final double maxScroll = _scrollController.position.maxScrollExtent; + final double currentScroll = _scrollController.offset; + return currentScroll >= maxScroll * 0.9; + } + + void _onScroll() { + if (_isBottom) { + ref.read(categoriesPageNotifierProvider.notifier).fetchNextPage(_searchText); + } + } + + HBTableStatus get _tableStatus { + final CategoriesPageState pageState = ref.read(categoriesPageNotifierProvider); + if (pageState.hasCategories) return HBTableStatus.data; + if (pageState.hasError || !pageState.hasCategories) return HBTableStatus.text; + return HBTableStatus.loading; + } + + String? get _tableText { + final CategoriesPageState pageState = ref.read(categoriesPageNotifierProvider); + if (!pageState.isLoading && !pageState.hasError && !pageState.hasCategories) return 'Keine Kategorien gefunden.'; + if (pageState.hasError) return 'Ein Fehler ist aufgetreten.'; + return null; + } + + String get _searchText { + return _searchController.text.trim(); + } + + Future _onCategoryPressed(int categoryId) async { + await CategoryDetailsRoute(categoryId: categoryId).push(context); + } + + Future _onCreateCategory() async { + final int? categoryId = await const CreateCategoryRoute().push(context); + if (categoryId != null && mounted) await CategoryDetailsRoute(categoryId: categoryId).push(context); + } + + Future _onSearchChanged(String _) async { + await ref.read(categoriesPageNotifierProvider.notifier).refresh(_searchText); + } + + Future _onRefresh() async { + await ref.read(categoriesPageNotifierProvider.notifier).refresh(_searchText); + } + + @override + void initState() { + super.initState(); + + _scrollController.addListener(_onScroll); + + CoreUtils.postFrameCall(() { + ref.read(categoriesPageNotifierProvider.notifier).fetchNextPage(_searchText); + }); + } + + @override + void dispose() { + _scrollController + ..removeListener(_onScroll) + ..dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + + final CategoriesPageState pageState = ref.watch(categoriesPageNotifierProvider); + + _searchController = useTextEditingController(); + + ref.listen( + categoriesPageNotifierProvider, + _onPageStateUpdate + ); + + return HBScaffold( + appBar: HBAppBar( + context: context, + title: 'Kategorien' + ), + body: Column( + children: [ + Padding( + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + top: HBSpacing.lg + ), + child: Row( + children: [ + HBTextField( + controller: _searchController, + onChanged: _onSearchChanged, + icon: HBIcons.magnifyingGlass, + hint: 'Name', + maxWidth: 500.0 + ), + const HBGap.lg(), + const Spacer(), + HBButton.shrinkFill( + onPressed: _onCreateCategory, + icon: HBIcons.plus, + title: 'Neue Kategorie' + ), + const HBGap.md(), + HBButton.shrinkFill( + onPressed: _onRefresh, + icon: HBIcons.arrowPath + ) + ] + ) + ), + Expanded( + child: HBTable( + onPressed: (int index) => _onCategoryPressed(pageState.categories[index].id), + status: _tableStatus, + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + bottom: context.bottomPadding + HBSpacing.lg, + top: HBSpacing.xxl + ), + controller: _scrollController, + tableWidth: context.width - HBUIConstants.navigationRailWidth - context.rightPadding - 4.0 * HBSpacing.lg, + columnLength: 1, + fractions: const [ 1.0 ], + titles: const [ 'Name' ], + items: List.generate(pageState.categories.length, (int index) { + final CategoryEntity category = pageState.categories[index]; + return [ + HBTableText(text: category.name) + ]; + }), + text: _tableText + ) + ) + ] + ) + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/categories/presentation/pages/category_details_page.dart b/flutter/lib/src/features/categories/presentation/pages/category_details_page.dart new file mode 100644 index 0000000..576c0b1 --- /dev/null +++ b/flutter/lib/src/features/categories/presentation/pages/category_details_page.dart @@ -0,0 +1,267 @@ +import 'package:flutter/material.dart'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:habib_app/src/features/categories/domain/usecases/category_delete_category_usecase.dart'; +import 'package:habib_app/src/features/categories/domain/entities/category_details_entity.dart'; +import 'package:habib_app/src/features/categories/presentation/app/category_details_page_notifier.dart'; +import 'package:habib_app/src/features/categories/presentation/app/category_update_category_usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/core/utils/validator.dart'; +import 'package:habib_app/core/res/theme/colors/hb_colors.dart'; +import 'package:habib_app/core/res/theme/typography/hb_typography.dart'; +import 'package:habib_app/core/common/widgets/hb_gap.dart'; +import 'package:habib_app/core/common/widgets/hb_text_field.dart'; +import 'package:habib_app/core/extensions/context_extension.dart'; +import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; +import 'package:habib_app/core/common/models/error_details.dart'; +import 'package:habib_app/core/common/widgets/hb_message_box.dart'; +import 'package:habib_app/core/utils/result.dart'; +import 'package:habib_app/core/extensions/object_extension.dart'; +import 'package:habib_app/core/utils/enums/toast_type.dart'; +import 'package:habib_app/core/common/widgets/hb_app_bar.dart'; +import 'package:habib_app/core/common/widgets/hb_scaffold.dart'; +import 'package:habib_app/core/res/hb_icons.dart'; +import 'package:habib_app/core/utils/core_utils.dart'; + +class CategoryDetailsPageParams { + + final int categoryId; + + const CategoryDetailsPageParams({ + required this.categoryId + }); +} + +class CategoryDetailsPage extends StatefulHookConsumerWidget { + + final CategoryDetailsPageParams params; + + const CategoryDetailsPage({ + super.key, + required this.params + }); + + @override + ConsumerState createState() => _CategoryDetailsPageState(); +} + +class _CategoryDetailsPageState extends ConsumerState { + + late TextEditingController _nameController; + + void _onDetailsStateUpdate(CategoryDetailsPageState? _, CategoryDetailsPageState next) { + if (next.hasError) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: next.error!.errorTitle, + description: next.error!.errorDescription, + ); + } + } + + Future _onRefresh() async { + ref.read(categoryDetailsPageNotifierProvider(widget.params.categoryId).notifier).fetch(); + } + + Future _onSave() async { + final CategoryDetailsPageState pageState = ref.watch(categoryDetailsPageNotifierProvider(widget.params.categoryId)); + final CategoryDetailsEntity category = pageState.category!; + + final String name = _nameController.text.trim(); + + try { + Validator.validateCategoryUpdate(name: name); + } catch (e) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: e.errorTitle, + description: e.errorDescription, + ); + return; + } + + final bool replaceName = name != category.name; + + Json categoryJson = { + if (replaceName) 'name' : "'$name'" + }; + + if (categoryJson.isEmpty) return; + + final CategoryUpdateCategoryUsecase categoryUpdateCategoryUsecase = ref.read(categoryUpdateCategoryUsecaseProvider); + final CategoryUpdateCategoryUsecaseParams categoryUpdateCategoryUsecaseParams = CategoryUpdateCategoryUsecaseParams( + categoryId: category.id, + categoryJson: categoryJson + ); + final Result categoryUpdateCategoryUsecaseResult = await categoryUpdateCategoryUsecase.call(categoryUpdateCategoryUsecaseParams); + + categoryUpdateCategoryUsecaseResult.fold( + onSuccess: (void _) { + ref.read(categoryDetailsPageNotifierProvider(widget.params.categoryId).notifier).replace( + CategoryDetailsEntity( + id: category.id, + name: replaceName ? name : category.name + ) + ); + + CoreUtils.showToast( + context, + type: ToastType.success, + title: 'Erfolgreich aktualisiert.', + description: 'Die Kategorie wurde erfolgreich aktualisiert.' + ); + }, + onFailure: (Object error, StackTrace stackTrace) { + final ErrorDetails errorDetails = ErrorDetails( + error: error, + stackTrace: stackTrace + ); + CoreUtils.showToast( + context, + type: ToastType.error, + title: errorDetails.errorTitle, + description: errorDetails.errorDescription, + ); + } + ); + } + + Future _onDelete() async { + final bool? success = await showHBMessageBox( + context, + 'Diese Kategorie wirklich löschen?', + 'Wenn Sie diese Kategorie löschen, werden alle damit verbundenen Daten ebenfalls gelöscht. Dieser Vorgang kann nicht rückgängig gemacht werden.', + 'Löschen', + onPressed: () async { + final CategoryDeleteCategoryUsecase categoryDeleteCategoryUsecase = ref.read(categoryDeleteCategoryUsecaseProvider); + final CategoryDeleteCategoryUsecaseParams categoryDeleteCategoryUsecaseParams = CategoryDeleteCategoryUsecaseParams(categoryId: widget.params.categoryId); + final Result categoryDeleteCategoryUsecaseResult = await categoryDeleteCategoryUsecase.call(categoryDeleteCategoryUsecaseParams); + + return categoryDeleteCategoryUsecaseResult.fold( + onSuccess: (void _) => true, + onFailure: (Object error, StackTrace stackTrace) { + final ErrorDetails errorDetails = ErrorDetails( + error: error, + stackTrace: stackTrace + ); + CoreUtils.showToast( + context, + type: ToastType.error, + title: errorDetails.errorTitle, + description: errorDetails.errorDescription, + ); + return false; + } + ); + } + ); + + if (mounted && context.canPop() && (success ?? false)) context.pop(true); + } + + @override + void initState() { + super.initState(); + + CoreUtils.postFrameCall(() { + ref.read(categoryDetailsPageNotifierProvider(widget.params.categoryId).notifier).fetch(); + }); + } + + @override + Widget build(BuildContext context) { + + final CategoryDetailsPageState pageState = ref.watch(categoryDetailsPageNotifierProvider(widget.params.categoryId)); + + if (pageState.hasCategory) { + _nameController = useTextEditingController(text: pageState.category!.name); + } + + ref.listen( + categoryDetailsPageNotifierProvider(widget.params.categoryId), + _onDetailsStateUpdate + ); + + return HBScaffold( + appBar: HBAppBar( + context: context, + title: 'Details', + backButton: const HBAppBarBackButton(), + actionButtons: [ + HBAppBarButton( + onPressed: _onRefresh, + icon: HBIcons.arrowPath, + isEnabled: !pageState.isLoading + ), + HBAppBarButton( + onPressed: _onSave, + icon: HBIcons.cloudArrowUp, + isEnabled: pageState.hasCategory, + ), + HBAppBarButton( + onPressed: _onDelete, + icon: HBIcons.trash, + isEnabled: pageState.hasCategory + ) + ] + ), + body: pageState.hasCategory + ? SingleChildScrollView( + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + top: HBSpacing.lg + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Allgemeine Details', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 20.0, + fontWeight: FontWeight.w600, + color: HBColors.gray900 + ) + ), + const HBGap.xl(), + Row( + children: [ + Expanded( + child: HBTextField( + title: 'Name', + controller: _nameController, + icon: HBIcons.home + ) + ), + const HBGap.xl(), + const Spacer(), + const HBGap.xl(), + const Spacer() + ] + ) + ] + ) + ) + : pageState.hasError + ? Center( + child: Text( + pageState.error!.errorDescription, + textAlign: TextAlign.center, + style: HBTypography.base.copyWith( + fontSize: 14.0, + fontWeight: FontWeight.w600, + color: HBColors.gray900 + ), + ) + ) + : const SizedBox.shrink() + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/categories/presentation/pages/create_category_page.dart b/flutter/lib/src/features/categories/presentation/pages/create_category_page.dart new file mode 100644 index 0000000..4993e4d --- /dev/null +++ b/flutter/lib/src/features/categories/presentation/pages/create_category_page.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:habib_app/core/common/models/error_details.dart'; +import 'package:habib_app/core/common/widgets/hb_dialog.dart'; +import 'package:habib_app/core/common/widgets/hb_gap.dart'; +import 'package:habib_app/core/common/widgets/hb_text_field.dart'; +import 'package:habib_app/core/extensions/object_extension.dart'; +import 'package:habib_app/core/res/hb_icons.dart'; +import 'package:habib_app/core/utils/core_utils.dart'; +import 'package:habib_app/core/utils/enums/toast_type.dart'; +import 'package:habib_app/core/utils/result.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/core/utils/validator.dart'; +import 'package:habib_app/src/features/categories/domain/usecases/category_create_category_usecase.dart'; + +class CreateCategoryPage extends StatefulHookConsumerWidget { + + const CreateCategoryPage({ super.key }); + + @override + ConsumerState createState() => _CreateCategoryPageState(); +} + +class _CreateCategoryPageState extends ConsumerState { + + late TextEditingController _nameController; + + Future _handleCreate() async { + final String name = _nameController.text.trim(); + + try { + Validator.validateCategoryCreate(name: name); + } catch (e) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: e.errorTitle, + description: e.errorDescription, + ); + return; + } + + Json categoryJson = { + 'name' : "'$name'" + }; + + final CategoryCreateCategoryUsecase categoryCreateCategoryUsecase = ref.read(categoryCreateCategoryUsecaseProvider); + final CategoryCreateCategoryUsecaseParams categoryCreateCategoryUsecaseParams = CategoryCreateCategoryUsecaseParams(categoryJson: categoryJson); + final Result categoryCreateCategoryUsecaseResult = await categoryCreateCategoryUsecase.call(categoryCreateCategoryUsecaseParams); + + categoryCreateCategoryUsecaseResult.fold( + onSuccess: (int categoryId) async { + CoreUtils.showToast( + context, + type: ToastType.success, + title: 'Erfolgreich angelegt.', + description: 'Die Kategorie wurde erfolgreich angelegt.' + ); + + if (!mounted) return; + + context.pop(categoryId); + }, + onFailure: (Object error, StackTrace stackTrace) { + final ErrorDetails errorDetails = ErrorDetails( + error: error, + stackTrace: stackTrace + ); + CoreUtils.showToast( + context, + type: ToastType.error, + title: errorDetails.errorTitle, + description: errorDetails.errorDescription, + ); + } + ); + } + + @override + Widget build(BuildContext context) { + + _nameController = useTextEditingController(); + + return HBDialog( + title: 'Neue Kategorie', + actionButton: HBDialogActionButton( + onPressed: _handleCreate, + title: 'Erstellen' + ), + children: [ + const HBDialogSection( + title: 'Allgemeine Details', + isFirstSection: true + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Expanded( + child: HBTextField( + controller: _nameController, + icon: HBIcons.academicCap, + title: 'Name' + ) + ), + const HBGap.lg(), + const Spacer() + ] + ) + ] + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/categories/presentation/widgets/categories_selection_dialog.dart b/flutter/lib/src/features/categories/presentation/widgets/categories_selection_dialog.dart new file mode 100644 index 0000000..2337375 --- /dev/null +++ b/flutter/lib/src/features/categories/presentation/widgets/categories_selection_dialog.dart @@ -0,0 +1,239 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:habib_app/core/common/widgets/hb_button.dart'; +import 'package:habib_app/core/common/widgets/hb_gap.dart'; +import 'package:habib_app/core/common/widgets/hb_selection_dialog.dart'; +import 'package:habib_app/core/common/widgets/hb_table.dart'; +import 'package:habib_app/core/common/widgets/hb_text_field.dart'; +import 'package:habib_app/core/extensions/context_extension.dart'; +import 'package:habib_app/core/extensions/object_extension.dart'; +import 'package:habib_app/core/res/hb_icons.dart'; +import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; +import 'package:habib_app/core/services/routes.dart'; +import 'package:habib_app/core/utils/core_utils.dart'; +import 'package:habib_app/core/utils/enums/toast_type.dart'; +import 'package:habib_app/src/features/books/presentation/pages/create_book_page.dart'; +import 'package:habib_app/src/features/categories/domain/entities/category_entity.dart'; +import 'package:habib_app/src/features/categories/presentation/app/categories_page_notifier.dart'; + +Future?> showCategoriesSelectionDialog({ + required BuildContext context, + required List categories +}) async { + return await showHBSelectionDialog>( + context: context, + title: 'Kategorien wählen', + content: CategoriesSelectionDialog(categories: categories) + ); +} + +class CategoriesSelectionDialog extends StatefulHookConsumerWidget { + + final List categories; + + const CategoriesSelectionDialog({ + super.key, + required this.categories + }); + + @override + ConsumerState createState() => _CategoriesSelectionDialogState(); +} + +class _CategoriesSelectionDialogState extends ConsumerState { + + late ValueNotifier> _categoriesNotifier; + + final ScrollController _scrollController = ScrollController(); + + late TextEditingController _searchController; + + void _onPageStateUpdate(CategoriesPageState? _, CategoriesPageState next) { + if (next.hasError) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: next.error!.errorTitle, + description: next.error!.errorDescription, + ); + } + } + + bool get _isBottom { + if (!_scrollController.hasClients) return false; + final double maxScroll = _scrollController.position.maxScrollExtent; + final double currentScroll = _scrollController.offset; + return currentScroll >= maxScroll * 0.9; + } + + void _onScroll() { + if (_isBottom) { + ref.read(categoriesPageNotifierProvider.notifier).fetchNextPage(_searchText); + } + } + + HBTableStatus get _tableStatus { + final CategoriesPageState pageState = ref.read(categoriesPageNotifierProvider); + if (pageState.hasCategories) return HBTableStatus.data; + if (pageState.hasError || !pageState.hasCategories) return HBTableStatus.text; + return HBTableStatus.loading; + } + + String? get _tableText { + final CategoriesPageState pageState = ref.read(categoriesPageNotifierProvider); + if (!pageState.isLoading && !pageState.hasError && !pageState.hasCategories) return 'Keine Kategorien gefunden.'; + if (pageState.hasError) return 'Ein Fehler ist aufgetreten.'; + return null; + } + + String get _searchText { + return _searchController.text.trim(); + } + + Future _onCategoryPressed(int categoryId) async { + await CategoryDetailsRoute(categoryId: categoryId).push(context); + } + + Future _onSearchChanged(String _) async { + await ref.read(categoriesPageNotifierProvider.notifier).refresh(_searchText); + } + + Future _onRefresh() async { + await ref.read(categoriesPageNotifierProvider.notifier).refresh(_searchText); + } + + void _onRowPressed(CategoryEntity selectedCategory) async { + if (_categoriesNotifier.value.map((CreateBookCategory category) => category.id).contains(selectedCategory.id)) { + _categoriesNotifier.value = _categoriesNotifier.value.where((CreateBookCategory category) => category.id != selectedCategory.id).toList(); + } else { + final CreateBookCategory newCategory = CreateBookCategory( + id: selectedCategory.id, + name: selectedCategory.name + ); + _categoriesNotifier.value = [ ..._categoriesNotifier.value, newCategory ]; + } + } + + void _cancel() { + context.pop(); + } + + void _onChoose() { + context.pop>(_categoriesNotifier.value); + } + + @override + void initState() { + super.initState(); + + _scrollController.addListener(_onScroll); + + CoreUtils.postFrameCall(() { + ref.read(categoriesPageNotifierProvider.notifier).fetchNextPage(_searchText); + }); + } + + @override + void dispose() { + _scrollController + ..removeListener(_onScroll) + ..dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + + _categoriesNotifier = useState>(widget.categories); + + final CategoriesPageState pageState = ref.watch(categoriesPageNotifierProvider); + + _searchController = useTextEditingController(); + + ref.listen( + categoriesPageNotifierProvider, + _onPageStateUpdate + ); + + return Column( + children: [ + Padding( + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + top: HBSpacing.lg + ), + child: Row( + children: [ + Expanded( + child: HBTextField( + controller: _searchController, + onChanged: _onSearchChanged, + icon: HBIcons.magnifyingGlass, + hint: 'Name' + ) + ), + const HBGap.lg(), + HBButton.shrinkFill( + onPressed: _onRefresh, + icon: HBIcons.arrowPath + ) + ] + ) + ), + Expanded( + child: HBTable( + onPressed: (int index) => _onRowPressed(pageState.categories[index]), + status: _tableStatus, + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + bottom: context.bottomPadding + HBSpacing.lg, + top: HBSpacing.xxl + ), + controller: _scrollController, + tableWidth: 800.0 - 4.0 * HBSpacing.lg, + columnLength: 2, + fractions: const [ 0.95, 0.05 ], + titles: const [ 'Name', '' ], + items: List.generate(pageState.categories.length, (int index) { + final CategoryEntity category = pageState.categories[index]; + final bool isSelected = _categoriesNotifier.value.map((CreateBookCategory category) => category.id).contains(category.id); + return [ + HBTableText( + onPressed: () => _onCategoryPressed(category.id), + text: category.name + ), + HBTableRadioIndicator(isSelected: isSelected) + ]; + }), + text: _tableText + ) + ), + const HBGap.lg(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: HBSpacing.lg), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + HBButton.shrinkFill( + onPressed: _onChoose, + title: 'Wählen' + ), + const HBGap.md(), + HBButton.shrinkOutline( + onPressed: _cancel, + title: 'Abbrechen' + ) + ] + ) + ) + ] + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/customers/data/datasources/customer_datasource.dart b/flutter/lib/src/features/customers/data/datasources/customer_datasource.dart index 7b3e382..315e7a6 100644 --- a/flutter/lib/src/features/customers/data/datasources/customer_datasource.dart +++ b/flutter/lib/src/features/customers/data/datasources/customer_datasource.dart @@ -1,5 +1,8 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/customers/data/dto/customer_details_dto.dart'; +import 'package:habib_app/src/features/customers/data/dto/customer_borrow_dto.dart'; import 'package:habib_app/src/features/customers/data/datasources/customer_datasource_impl.dart'; import 'package:habib_app/src/features/customers/data/dto/customer_dto.dart'; import 'package:habib_app/core/services/database.dart'; @@ -18,4 +21,22 @@ abstract interface class CustomerDatasource { const CustomerDatasource(); Future> getCustomers({ required String searchText, required int currentPage }); + + Future> getCustomerBorrows({ required int customerId, required String searchText, required int currentPage }); + + Future getCustomer({ required int customerId }); + + Future createCustomer({ + required Json addressJson, + required Json customerJson + }); + + Future updateCustomer({ + required int customerId, + required int addressId, + required Json customerJson, + required Json addressJson + }); + + Future deleteCustomer({ required int customerId }); } \ No newline at end of file diff --git a/flutter/lib/src/features/customers/data/datasources/customer_datasource_impl.dart b/flutter/lib/src/features/customers/data/datasources/customer_datasource_impl.dart index 8dc9e0e..cc644fc 100644 --- a/flutter/lib/src/features/customers/data/datasources/customer_datasource_impl.dart +++ b/flutter/lib/src/features/customers/data/datasources/customer_datasource_impl.dart @@ -1,6 +1,8 @@ import 'package:habib_app/core/utils/typedefs.dart'; import 'package:habib_app/core/services/database.dart'; import 'package:habib_app/src/features/customers/data/datasources/customer_datasource.dart'; +import 'package:habib_app/src/features/customers/data/dto/customer_borrow_dto.dart'; +import 'package:habib_app/src/features/customers/data/dto/customer_details_dto.dart'; import 'package:habib_app/src/features/customers/data/dto/customer_dto.dart'; class CustomerDatasourceImpl implements CustomerDatasource { @@ -19,4 +21,46 @@ class CustomerDatasourceImpl implements CustomerDatasource { ); return CustomerDto.listFromJsonList(jsonList); } + + @override + Future> getCustomerBorrows({ required int customerId, required String searchText, required int currentPage }) async { + final List jsonList = await _database.getCustomerBorrows( + customerId: customerId, + searchText: searchText, + currentPage: currentPage + ); + return CustomerBorrowDto.listFromJsonList(jsonList); + } + + @override + Future getCustomer({ required int customerId }) async { + final Json json = await _database.getCustomer(customerId: customerId); + return CustomerDetailsDto.fromJson(json); + } + + @override + Future createCustomer({ + required Json addressJson, + required Json customerJson + }) async { + return await _database.createCustomer( + addressJson: addressJson, + customerJson: customerJson + ); + } + + @override + Future updateCustomer({ + required int customerId, + required int addressId, + required Json customerJson, + required Json addressJson + }) async { + return await _database.updateCustomer(customerId, addressId, customerJson, addressJson); + } + + @override + Future deleteCustomer({ required int customerId }) async { + return await _database.deleteCustomer(customerId); + } } \ No newline at end of file diff --git a/flutter/lib/src/features/customers/data/dto/customer_address_dto.dart b/flutter/lib/src/features/customers/data/dto/customer_address_dto.dart index 2a5d683..e7596d5 100644 --- a/flutter/lib/src/features/customers/data/dto/customer_address_dto.dart +++ b/flutter/lib/src/features/customers/data/dto/customer_address_dto.dart @@ -1,7 +1,7 @@ import 'package:habib_app/core/utils/typedefs.dart'; -import 'package:habib_app/src/features/customers/domain/entities/address_entity.dart'; +import 'package:habib_app/src/features/customers/domain/entities/customer_address_entity.dart'; -class CustomerAddressDto extends AddressEntity { +class CustomerAddressDto extends CustomerAddressEntity { const CustomerAddressDto({ required super.id, diff --git a/flutter/lib/src/features/customers/data/dto/customer_borrow_book_dto.dart b/flutter/lib/src/features/customers/data/dto/customer_borrow_book_dto.dart new file mode 100644 index 0000000..2a05538 --- /dev/null +++ b/flutter/lib/src/features/customers/data/dto/customer_borrow_book_dto.dart @@ -0,0 +1,19 @@ +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/customers/domain/entities/customer_borrow_book_entity.dart'; + +class CustomerBorrowBookDto extends CustomerBorrowBookEntity { + + const CustomerBorrowBookDto({ + required super.id, + required super.title, + super.edition, + }); + + factory CustomerBorrowBookDto.fromJson(Json bookJson) { + return CustomerBorrowBookDto( + id: bookJson['book_id'] as int, + title: bookJson['book_title'] as String, + edition: bookJson['book_edition'] as int? + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/customers/data/dto/customer_borrow_dto.dart b/flutter/lib/src/features/customers/data/dto/customer_borrow_dto.dart new file mode 100644 index 0000000..0ef0637 --- /dev/null +++ b/flutter/lib/src/features/customers/data/dto/customer_borrow_dto.dart @@ -0,0 +1,29 @@ +import 'dart:convert'; + +import 'package:habib_app/core/utils/enums/borrow_status.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/customers/data/dto/customer_borrow_book_dto.dart'; +import 'package:habib_app/src/features/customers/domain/entities/customer_borrow_entity.dart'; + +class CustomerBorrowDto extends CustomerBorrowEntity { + + const CustomerBorrowDto({ + required super.id, + required super.book, + required super.endDate, + required super.status + }); + + factory CustomerBorrowDto.fromJson(Json borrowJson) { + return CustomerBorrowDto( + id: borrowJson['borrow_id'] as int, + book: CustomerBorrowBookDto.fromJson(json.decode(borrowJson['book'] as String)), + endDate: borrowJson['borrow_end_date'] as DateTime, + status: BorrowStatus.fromDatabaseValue(borrowJson['borrow_status'] as String) + ); + } + + static List listFromJsonList(List jsonList) { + return jsonList.map((Json json) => CustomerBorrowDto.fromJson(json)).toList(); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/customers/data/dto/customer_details_address_dto.dart b/flutter/lib/src/features/customers/data/dto/customer_details_address_dto.dart new file mode 100644 index 0000000..50fbe6c --- /dev/null +++ b/flutter/lib/src/features/customers/data/dto/customer_details_address_dto.dart @@ -0,0 +1,21 @@ +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/customers/domain/entities/customer_details_address_entity.dart'; + +class CustomerDetailsAddressDto extends CustomerDetailsAddressEntity { + + const CustomerDetailsAddressDto({ + required super.id, + required super.street, + required super.city, + required super.postalCode + }); + + factory CustomerDetailsAddressDto.fromJson(Json addressJson) { + return CustomerDetailsAddressDto( + id: addressJson['address_id'] as int, + city: addressJson['address_city'] as String, + postalCode: addressJson['address_postal_code'] as String, + street: addressJson['address_street'] as String + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/customers/data/dto/customer_details_dto.dart b/flutter/lib/src/features/customers/data/dto/customer_details_dto.dart new file mode 100644 index 0000000..42815fb --- /dev/null +++ b/flutter/lib/src/features/customers/data/dto/customer_details_dto.dart @@ -0,0 +1,32 @@ +import 'dart:convert'; + +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/customers/data/dto/customer_details_address_dto.dart'; +import 'package:habib_app/src/features/customers/domain/entities/customer_details_entity.dart'; + +class CustomerDetailsDto extends CustomerDetailsEntity { + + const CustomerDetailsDto({ + required super.id, + required super.firstName, + required super.lastName, + super.occupation, + super.title, + super.phone, + super.mobile, + required super.address + }); + + factory CustomerDetailsDto.fromJson(Json customerJson) { + return CustomerDetailsDto( + id: customerJson['customer_id'] as int, + firstName: customerJson['customer_first_name'] as String, + lastName: customerJson['customer_last_name'] as String, + occupation: customerJson['customer_occupation'] as String?, + title: customerJson['customer_title'] as String?, + phone: customerJson['customer_phone'] as String?, + mobile: customerJson['customer_mobile'] as String?, + address: CustomerDetailsAddressDto.fromJson(json.decode(customerJson['address'] as String)) + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/customers/data/repositories/customer_repository_impl.dart b/flutter/lib/src/features/customers/data/repositories/customer_repository_impl.dart index 65844bd..52f46bd 100644 --- a/flutter/lib/src/features/customers/data/repositories/customer_repository_impl.dart +++ b/flutter/lib/src/features/customers/data/repositories/customer_repository_impl.dart @@ -1,7 +1,11 @@ import 'package:habib_app/core/utils/result.dart'; import 'package:habib_app/core/utils/typedefs.dart'; import 'package:habib_app/src/features/customers/data/datasources/customer_datasource.dart'; +import 'package:habib_app/src/features/customers/data/dto/customer_borrow_dto.dart'; +import 'package:habib_app/src/features/customers/data/dto/customer_details_dto.dart'; import 'package:habib_app/src/features/customers/data/dto/customer_dto.dart'; +import 'package:habib_app/src/features/customers/domain/entities/customer_borrow_entity.dart'; +import 'package:habib_app/src/features/customers/domain/entities/customer_details_entity.dart'; import 'package:habib_app/src/features/customers/domain/entities/customer_entity.dart'; import 'package:habib_app/src/features/customers/domain/repositories/customer_repository.dart'; @@ -21,7 +25,77 @@ class CustomerRepositoryImpl implements CustomerRepository { currentPage: currentPage ); return Success(result); - } on Exception catch (e) { + } catch (e) { + return Failure(e); + } + } + + @override + ResultFuture> getCustomerBorrows({ required int customerId, required String searchText, required int currentPage }) async { + try { + final List result = await _customerDatasource.getCustomerBorrows( + customerId: customerId, + searchText: searchText, + currentPage: currentPage + ); + return Success(result); + } catch (e) { + return Failure(e); + } + } + + @override + ResultFuture getCustomer({ required int customerId }) async { + try { + final CustomerDetailsDto result = await _customerDatasource.getCustomer(customerId: customerId); + return Success(result); + } catch (e) { + return Failure(e); + } + } + + @override + ResultFuture createCustomer({ + required Json addressJson, + required Json customerJson + }) async { + try { + final int customerId = await _customerDatasource.createCustomer( + addressJson: addressJson, + customerJson: customerJson + ); + return Success(customerId); + } catch (e) { + return Failure(e); + } + } + + @override + ResultFuture updateCustomer({ + required int customerId, + required int addressId, + required Json customerJson, + required Json addressJson + }) async { + try { + await _customerDatasource.updateCustomer( + customerId: customerId, + addressId: addressId, + customerJson: customerJson, + addressJson: addressJson + ); + return const Success(null); + } catch (e) { + return Failure(e); + } + } + + @override + ResultFuture deleteCustomer({ required int customerId }) async { + try { + await _customerDatasource.deleteCustomer(customerId: customerId); + return const Success(null); + } catch (e) { return Failure(e); } } diff --git a/flutter/lib/src/features/customers/domain/entities/address_entity.dart b/flutter/lib/src/features/customers/domain/entities/customer_address_entity.dart similarity index 80% rename from flutter/lib/src/features/customers/domain/entities/address_entity.dart rename to flutter/lib/src/features/customers/domain/entities/customer_address_entity.dart index 32d1af7..f220a00 100644 --- a/flutter/lib/src/features/customers/domain/entities/address_entity.dart +++ b/flutter/lib/src/features/customers/domain/entities/customer_address_entity.dart @@ -1,13 +1,13 @@ import 'package:equatable/equatable.dart'; -class AddressEntity extends Equatable { +class CustomerAddressEntity extends Equatable { final int id; final String city; final String postalCode; final String street; - const AddressEntity({ + const CustomerAddressEntity({ required this.id, required this.city, required this.postalCode, diff --git a/flutter/lib/src/features/customers/domain/entities/customer_borrow_book_entity.dart b/flutter/lib/src/features/customers/domain/entities/customer_borrow_book_entity.dart new file mode 100644 index 0000000..40c1921 --- /dev/null +++ b/flutter/lib/src/features/customers/domain/entities/customer_borrow_book_entity.dart @@ -0,0 +1,21 @@ +import 'package:equatable/equatable.dart'; + +class CustomerBorrowBookEntity extends Equatable { + + final int id; + final String title; + final int? edition; + + const CustomerBorrowBookEntity({ + required this.id, + required this.title, + this.edition + }); + + @override + List get props => [ + id, + title, + edition + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/customers/domain/entities/customer_borrow_entity.dart b/flutter/lib/src/features/customers/domain/entities/customer_borrow_entity.dart new file mode 100644 index 0000000..a954b28 --- /dev/null +++ b/flutter/lib/src/features/customers/domain/entities/customer_borrow_entity.dart @@ -0,0 +1,27 @@ +import 'package:equatable/equatable.dart'; + +import 'package:habib_app/core/utils/enums/borrow_status.dart'; +import 'package:habib_app/src/features/customers/domain/entities/customer_borrow_book_entity.dart'; + +class CustomerBorrowEntity extends Equatable { + + final int id; + final CustomerBorrowBookEntity book; + final DateTime endDate; + final BorrowStatus status; + + const CustomerBorrowEntity({ + required this.id, + required this.book, + required this.endDate, + required this.status + }); + + @override + List get props => [ + id, + book, + endDate, + status + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/customers/domain/entities/customer_details_address_entity.dart b/flutter/lib/src/features/customers/domain/entities/customer_details_address_entity.dart new file mode 100644 index 0000000..cda3a74 --- /dev/null +++ b/flutter/lib/src/features/customers/domain/entities/customer_details_address_entity.dart @@ -0,0 +1,24 @@ +import 'package:equatable/equatable.dart'; + +class CustomerDetailsAddressEntity extends Equatable { + + final int id; + final String city; + final String postalCode; + final String street; + + const CustomerDetailsAddressEntity({ + required this.id, + required this.city, + required this.postalCode, + required this.street + }); + + @override + List get props => [ + id, + city, + postalCode, + street + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/customers/domain/entities/customer_details_entity.dart b/flutter/lib/src/features/customers/domain/entities/customer_details_entity.dart new file mode 100644 index 0000000..181c0df --- /dev/null +++ b/flutter/lib/src/features/customers/domain/entities/customer_details_entity.dart @@ -0,0 +1,38 @@ +import 'package:equatable/equatable.dart'; + +import 'package:habib_app/src/features/customers/domain/entities/customer_details_address_entity.dart'; + +class CustomerDetailsEntity extends Equatable { + + final int id; + final String firstName; + final String lastName; + final String? occupation; + final String? title; + final String? phone; + final String? mobile; + final CustomerDetailsAddressEntity address; + + const CustomerDetailsEntity({ + required this.id, + required this.firstName, + required this.lastName, + this.occupation, + this.title, + this.phone, + this.mobile, + required this.address + }); + + @override + List get props => [ + id, + firstName, + lastName, + occupation, + title, + phone, + mobile, + address + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/customers/domain/entities/customer_entity.dart b/flutter/lib/src/features/customers/domain/entities/customer_entity.dart index d86035b..da825f3 100644 --- a/flutter/lib/src/features/customers/domain/entities/customer_entity.dart +++ b/flutter/lib/src/features/customers/domain/entities/customer_entity.dart @@ -1,6 +1,6 @@ import 'package:equatable/equatable.dart'; -import 'package:habib_app/src/features/customers/domain/entities/address_entity.dart'; +import 'package:habib_app/src/features/customers/domain/entities/customer_address_entity.dart'; class CustomerEntity extends Equatable { @@ -10,7 +10,7 @@ class CustomerEntity extends Equatable { final String? title; final String? phone; final String? mobile; - final AddressEntity address; + final CustomerAddressEntity address; const CustomerEntity({ required this.id, diff --git a/flutter/lib/src/features/customers/domain/repositories/customer_repository.dart b/flutter/lib/src/features/customers/domain/repositories/customer_repository.dart index 4a7fa17..5fdcb41 100644 --- a/flutter/lib/src/features/customers/domain/repositories/customer_repository.dart +++ b/flutter/lib/src/features/customers/domain/repositories/customer_repository.dart @@ -1,5 +1,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:habib_app/src/features/customers/domain/entities/customer_details_entity.dart'; +import 'package:habib_app/src/features/customers/domain/entities/customer_borrow_entity.dart'; import 'package:habib_app/src/features/customers/data/datasources/customer_datasource.dart'; import 'package:habib_app/src/features/customers/domain/entities/customer_entity.dart'; import 'package:habib_app/core/utils/typedefs.dart'; @@ -17,4 +19,22 @@ CustomerRepository customerRepository(CustomerRepositoryRef ref) { abstract interface class CustomerRepository { ResultFuture> getCustomers({ required String searchText, required int currentPage }); + + ResultFuture> getCustomerBorrows({ required int customerId, required String searchText, required int currentPage }); + + ResultFuture getCustomer({ required int customerId }); + + ResultFuture createCustomer({ + required Json addressJson, + required Json customerJson + }); + + ResultFuture updateCustomer({ + required int customerId, + required int addressId, + required Json customerJson, + required Json addressJson + }); + + ResultFuture deleteCustomer({ required int customerId }); } \ No newline at end of file diff --git a/flutter/lib/src/features/customers/domain/usecases/customer_create_customer_usecase.dart b/flutter/lib/src/features/customers/domain/usecases/customer_create_customer_usecase.dart new file mode 100644 index 0000000..8abbab1 --- /dev/null +++ b/flutter/lib/src/features/customers/domain/usecases/customer_create_customer_usecase.dart @@ -0,0 +1,49 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/customers/domain/repositories/customer_repository.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'customer_create_customer_usecase.g.dart'; + +@riverpod +CustomerCreateCustomerUsecase customerCreateCustomerUsecase(CustomerCreateCustomerUsecaseRef ref) { + return CustomerCreateCustomerUsecase( + customerRepository: ref.read(customerRepositoryProvider) + ); +} + +class CustomerCreateCustomerUsecase extends UsecaseWithParams { + + final CustomerRepository _customerRepository; + + const CustomerCreateCustomerUsecase({ + required CustomerRepository customerRepository + }) : _customerRepository = customerRepository; + + @override + ResultFuture call(CustomerCreateCustomerUsecaseParams params) async { + return await _customerRepository.createCustomer( + addressJson: params.addressJson, + customerJson: params.customerJson + ); + } +} + +class CustomerCreateCustomerUsecaseParams extends Equatable { + + final Json addressJson; + final Json customerJson; + + const CustomerCreateCustomerUsecaseParams({ + required this.addressJson, + required this.customerJson + }); + + @override + List get props => [ + addressJson, + customerJson + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/customers/domain/usecases/customer_delete_customer_usecase.dart b/flutter/lib/src/features/customers/domain/usecases/customer_delete_customer_usecase.dart new file mode 100644 index 0000000..1321885 --- /dev/null +++ b/flutter/lib/src/features/customers/domain/usecases/customer_delete_customer_usecase.dart @@ -0,0 +1,41 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/customers/domain/repositories/customer_repository.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'customer_delete_customer_usecase.g.dart'; + +@riverpod +CustomerDeleteCustomerUsecase customerDeleteCustomerUsecase(CustomerDeleteCustomerUsecaseRef ref) { + return CustomerDeleteCustomerUsecase( + customerRepository: ref.read(customerRepositoryProvider) + ); +} + +class CustomerDeleteCustomerUsecase extends UsecaseWithParams { + + final CustomerRepository _customerRepository; + + const CustomerDeleteCustomerUsecase({ + required CustomerRepository customerRepository + }) : _customerRepository = customerRepository; + + @override + ResultFuture call(CustomerDeleteCustomerUsecaseParams params) async { + return await _customerRepository.deleteCustomer(customerId: params.customerId); + } +} + +class CustomerDeleteCustomerUsecaseParams extends Equatable { + + final int customerId; + + const CustomerDeleteCustomerUsecaseParams({ required this.customerId }); + + @override + List get props => [ + customerId + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/customers/domain/usecases/customer_get_customer_borrows_usecase.dart b/flutter/lib/src/features/customers/domain/usecases/customer_get_customer_borrows_usecase.dart new file mode 100644 index 0000000..b73b7f9 --- /dev/null +++ b/flutter/lib/src/features/customers/domain/usecases/customer_get_customer_borrows_usecase.dart @@ -0,0 +1,54 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/customers/domain/entities/customer_borrow_entity.dart'; +import 'package:habib_app/src/features/customers/domain/repositories/customer_repository.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'customer_get_customer_borrows_usecase.g.dart'; + +@riverpod +CustomerGetCustomerBorrowsUsecase customerGetCustomerBorrowsUsecase(CustomerGetCustomerBorrowsUsecaseRef ref) { + return CustomerGetCustomerBorrowsUsecase( + customerRepository: ref.read(customerRepositoryProvider) + ); +} + +class CustomerGetCustomerBorrowsUsecase extends UsecaseWithParams, CustomerGetCustomerBorrowsUsecaseParams> { + + final CustomerRepository _customerRepository; + + const CustomerGetCustomerBorrowsUsecase({ + required CustomerRepository customerRepository + }) : _customerRepository = customerRepository; + + @override + ResultFuture> call(CustomerGetCustomerBorrowsUsecaseParams params) async { + return await _customerRepository.getCustomerBorrows( + customerId: params.customerId, + searchText: params.searchText, + currentPage: params.currentPage + ); + } +} + +class CustomerGetCustomerBorrowsUsecaseParams extends Equatable { + + final int customerId; + final String searchText; + final int currentPage; + + const CustomerGetCustomerBorrowsUsecaseParams({ + required this.customerId, + required this.searchText, + required this.currentPage + }); + + @override + List get props => [ + customerId, + searchText, + currentPage + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/customers/domain/usecases/customer_get_customer_usecase.dart b/flutter/lib/src/features/customers/domain/usecases/customer_get_customer_usecase.dart new file mode 100644 index 0000000..1c336aa --- /dev/null +++ b/flutter/lib/src/features/customers/domain/usecases/customer_get_customer_usecase.dart @@ -0,0 +1,42 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/customers/domain/entities/customer_details_entity.dart'; +import 'package:habib_app/src/features/customers/domain/repositories/customer_repository.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'customer_get_customer_usecase.g.dart'; + +@riverpod +CustomerGetCustomerUsecase customerGetCustomerUsecase(CustomerGetCustomerUsecaseRef ref) { + return CustomerGetCustomerUsecase( + customerRepository: ref.read(customerRepositoryProvider) + ); +} + +class CustomerGetCustomerUsecase extends UsecaseWithParams { + + final CustomerRepository _customerRepository; + + const CustomerGetCustomerUsecase({ + required CustomerRepository customerRepository + }) : _customerRepository = customerRepository; + + @override + ResultFuture call(CustomerGetCustomerUsecaseParams params) async { + return await _customerRepository.getCustomer(customerId: params.customerId); + } +} + +class CustomerGetCustomerUsecaseParams extends Equatable { + + final int customerId; + + const CustomerGetCustomerUsecaseParams({ required this.customerId }); + + @override + List get props => [ + customerId + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/customers/domain/usecases/customer_update_customer_usecase.dart b/flutter/lib/src/features/customers/domain/usecases/customer_update_customer_usecase.dart new file mode 100644 index 0000000..874a805 --- /dev/null +++ b/flutter/lib/src/features/customers/domain/usecases/customer_update_customer_usecase.dart @@ -0,0 +1,57 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/customers/domain/repositories/customer_repository.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'customer_update_customer_usecase.g.dart'; + +@riverpod +CustomerUpdateCustomerUsecase customerUpdateCustomerUsecase(CustomerUpdateCustomerUsecaseRef ref) { + return CustomerUpdateCustomerUsecase( + customerRepository: ref.read(customerRepositoryProvider) + ); +} + +class CustomerUpdateCustomerUsecase extends UsecaseWithParams { + + final CustomerRepository _customerRepository; + + const CustomerUpdateCustomerUsecase({ + required CustomerRepository customerRepository + }) : _customerRepository = customerRepository; + + @override + ResultFuture call(CustomerUpdateCustomerUsecaseParams params) async { + return await _customerRepository.updateCustomer( + customerId: params.customerId, + addressId: params.addressId, + customerJson: params.customerJson, + addressJson: params.addressJson + ); + } +} + +class CustomerUpdateCustomerUsecaseParams extends Equatable { + + final int customerId; + final int addressId; + final Json customerJson; + final Json addressJson; + + const CustomerUpdateCustomerUsecaseParams({ + required this.customerId, + required this.addressId, + required this.customerJson, + required this.addressJson + }); + + @override + List get props => [ + customerId, + addressId, + customerJson, + addressJson + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/customers/presentation/app/customer_borrows_notifier.dart b/flutter/lib/src/features/customers/presentation/app/customer_borrows_notifier.dart new file mode 100644 index 0000000..3f1dc3f --- /dev/null +++ b/flutter/lib/src/features/customers/presentation/app/customer_borrows_notifier.dart @@ -0,0 +1,113 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/core/common/models/error_details.dart'; +import 'package:habib_app/src/features/customers/domain/entities/customer_borrow_entity.dart'; +import 'package:habib_app/src/features/customers/domain/usecases/customer_get_customer_borrows_usecase.dart'; +import 'package:habib_app/core/utils/constants/network_constants.dart'; +import 'package:habib_app/core/utils/result.dart'; + +part 'customer_borrows_notifier.g.dart'; + +@riverpod +class CustomerBorrowsNotifier extends _$CustomerBorrowsNotifier { + + late CustomerGetCustomerBorrowsUsecase _customerGetCustomerBorrowsUsecase; + + @override + CustomerBorrowsState build(int customerId) { + _customerGetCustomerBorrowsUsecase = ref.read(customerGetCustomerBorrowsUsecaseProvider); + return const CustomerBorrowsState(); + } + + Future fetchNextPage(String searchText) async { + if (state.isLoading || state.hasReachedEnd) return; + + state = state.copyWith( + isCustomerBorrowsLoading: true, + removeError: true + ); + + final CustomerGetCustomerBorrowsUsecaseParams params = CustomerGetCustomerBorrowsUsecaseParams( + customerId: customerId, + searchText: searchText, + currentPage: state.currentPage + ); + final Result> result = await _customerGetCustomerBorrowsUsecase.call(params); + + result.fold( + onSuccess: (List customerBorrows) { + state = state.copyWith( + isCustomerBorrowsLoading: false, + currentPage: customerBorrows.isEmpty + ? state.currentPage + : state.currentPage + 1, + customerBorrows: List.of(state.customerBorrows)..addAll(customerBorrows), + hasReachedEnd: customerBorrows.length < NetworkConstants.pageSize + ); + }, + onFailure: (Object error, StackTrace stackTrace) { + state = state.copyWith( + isCustomerBorrowsLoading: false, + error: ErrorDetails( + error: error, + stackTrace: stackTrace + ) + ); + } + ); + } + + Future refresh(String searchText) async { + state = const CustomerBorrowsState(); + await fetchNextPage(searchText); + } +} + +class CustomerBorrowsState extends Equatable { + + final bool isCustomerBorrowsLoading; + final ErrorDetails? error; + final List customerBorrows; + final bool hasReachedEnd; + final int currentPage; + + const CustomerBorrowsState({ + this.isCustomerBorrowsLoading = false, + this.error, + this.customerBorrows = const [], + this.hasReachedEnd = false, + this.currentPage = 1 + }); + + bool get hasError => error != null; + bool get isLoading => isCustomerBorrowsLoading; + bool get hasCustomerBorrows => customerBorrows.isNotEmpty; + + CustomerBorrowsState copyWith({ + bool? isCustomerBorrowsLoading = false, + ErrorDetails? error, + List? customerBorrows, + bool? hasReachedEnd, + int? currentPage, + bool removeError = false, + bool removeCustomerBorrows = false + }) { + return CustomerBorrowsState( + isCustomerBorrowsLoading: isCustomerBorrowsLoading ?? this.isCustomerBorrowsLoading, + error: removeError ? null : error ?? this.error, + customerBorrows: removeCustomerBorrows ? const [] : customerBorrows ?? this.customerBorrows, + hasReachedEnd: hasReachedEnd ?? this.hasReachedEnd, + currentPage: currentPage ?? this.currentPage + ); + } + + @override + List get props => [ + isCustomerBorrowsLoading, + error, + customerBorrows, + hasReachedEnd, + currentPage + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/customers/presentation/app/customer_details_page_notifier.dart b/flutter/lib/src/features/customers/presentation/app/customer_details_page_notifier.dart new file mode 100644 index 0000000..d46fae0 --- /dev/null +++ b/flutter/lib/src/features/customers/presentation/app/customer_details_page_notifier.dart @@ -0,0 +1,93 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/core/common/models/error_details.dart'; +import 'package:habib_app/src/features/customers/domain/entities/customer_details_entity.dart'; +import 'package:habib_app/src/features/customers/domain/usecases/customer_get_customer_usecase.dart'; +import 'package:habib_app/core/utils/result.dart'; + +part 'customer_details_page_notifier.g.dart'; + +@riverpod +class CustomerDetailsPageNotifier extends _$CustomerDetailsPageNotifier { + + late CustomerGetCustomerUsecase _customerGetCustomerUsecase; + + @override + CustomerDetailsPageState build(int customerId) { + _customerGetCustomerUsecase = ref.read(customerGetCustomerUsecaseProvider); + return const CustomerDetailsPageState(); + } + + void replace(CustomerDetailsEntity customer) { + state = state.copyWith(customer: customer); + } + + Future fetch() async { + if (state.isLoading) return; + + state = state.copyWith( + isCustomerLoading: true, + removeError: true + ); + + final CustomerGetCustomerUsecaseParams customerParams = CustomerGetCustomerUsecaseParams(customerId: customerId); + final Result result = await _customerGetCustomerUsecase.call(customerParams); + + result.fold( + onSuccess: (CustomerDetailsEntity customer) { + state = state.copyWith( + isCustomerLoading: false, + customer: customer + ); + }, + onFailure: (Object error, StackTrace stackTrace) { + state = state.copyWith( + isCustomerLoading: false, + error: ErrorDetails( + error: error, + stackTrace: stackTrace + ) + ); + } + ); + } +} + +class CustomerDetailsPageState extends Equatable { + + final bool isCustomerLoading; + final ErrorDetails? error; + final CustomerDetailsEntity? customer; + + const CustomerDetailsPageState({ + this.isCustomerLoading = false, + this.error, + this.customer + }); + + bool get hasError => error != null; + bool get isLoading => isCustomerLoading; + bool get hasCustomer => customer != null; + + CustomerDetailsPageState copyWith({ + bool? isCustomerLoading = false, + ErrorDetails? error, + CustomerDetailsEntity? customer, + bool removeError = false, + bool removeCustomer = false + }) { + return CustomerDetailsPageState( + isCustomerLoading: isCustomerLoading ?? this.isCustomerLoading, + error: removeError ? null : error ?? this.error, + customer: removeCustomer ? null : customer ?? this.customer + ); + } + + @override + List get props => [ + isCustomerLoading, + error, + customer + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/customers/presentation/app/customers_page_notifier.dart b/flutter/lib/src/features/customers/presentation/app/customers_page_notifier.dart index 4085f8b..40e5bf7 100644 --- a/flutter/lib/src/features/customers/presentation/app/customers_page_notifier.dart +++ b/flutter/lib/src/features/customers/presentation/app/customers_page_notifier.dart @@ -1,7 +1,7 @@ import 'package:equatable/equatable.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; - +import 'package:habib_app/core/common/models/error_details.dart'; import 'package:habib_app/src/features/customers/domain/entities/customer_entity.dart'; import 'package:habib_app/src/features/customers/domain/usecases/customer_get_customers_usecase.dart'; import 'package:habib_app/core/utils/constants/network_constants.dart'; @@ -9,57 +9,6 @@ import 'package:habib_app/core/utils/result.dart'; part 'customers_page_notifier.g.dart'; -enum CustomersPageStatus { - initial, - loading, - success, - failure -} - -class CustomersPageState extends Equatable { - - final CustomersPageStatus status; - final Exception? exception; - final List customers; - final bool hasReachedEnd; - final int currentPage; - - const CustomersPageState({ - this.status = CustomersPageStatus.initial, - this.exception, - this.customers = const [], - this.hasReachedEnd = false, - this.currentPage = 1 - }); - - CustomersPageState copyWith({ - CustomersPageStatus? status, - Exception? exception, - List? customers, - bool? hasReachedEnd, - int? currentPage, - bool removeException = false, - bool removeCustomers = false - }) { - return CustomersPageState( - status: status ?? this.status, - exception: removeException ? null : exception ?? this.exception, - customers: removeCustomers ? [] : customers ?? this.customers, - hasReachedEnd: hasReachedEnd ?? this.hasReachedEnd, - currentPage: currentPage ?? this.currentPage - ); - } - - @override - List get props => [ - status, - exception, - customers, - hasReachedEnd, - currentPage - ]; -} - @riverpod class CustomersPageNotifier extends _$CustomersPageNotifier { @@ -72,12 +21,11 @@ class CustomersPageNotifier extends _$CustomersPageNotifier { } Future fetchNextPage(String searchText) async { - if (state.status == CustomersPageStatus.loading) return; - if (state.hasReachedEnd) return; - + if (state.isLoading || state.hasReachedEnd) return; + state = state.copyWith( - status: CustomersPageStatus.loading, - removeException: true + isCustomersLoading: true, + removeError: true ); final CustomerGetCustomersUsecaseParams params = CustomerGetCustomersUsecaseParams( @@ -89,19 +37,21 @@ class CustomersPageNotifier extends _$CustomersPageNotifier { result.fold( onSuccess: (List customers) { state = state.copyWith( - status: CustomersPageStatus.success, + isCustomersLoading: false, currentPage: customers.isEmpty ? state.currentPage : state.currentPage + 1, customers: List.of(state.customers)..addAll(customers), - hasReachedEnd: customers.length < NetworkConstants.pageSize, - removeException: true + hasReachedEnd: customers.length < NetworkConstants.pageSize ); - }, - onFailure: (Exception exception, StackTrace stackTrace) { + }, + onFailure: (Object error, StackTrace stackTrace) { state = state.copyWith( - status: CustomersPageStatus.failure, - exception: exception + isCustomersLoading: false, + error: ErrorDetails( + error: error, + stackTrace: stackTrace + ) ); } ); @@ -111,4 +61,52 @@ class CustomersPageNotifier extends _$CustomersPageNotifier { state = const CustomersPageState(); await fetchNextPage(searchText); } +} + +class CustomersPageState extends Equatable { + + final bool isCustomersLoading; + final ErrorDetails? error; + final List customers; + final bool hasReachedEnd; + final int currentPage; + + const CustomersPageState({ + this.isCustomersLoading = false, + this.error, + this.customers = const [], + this.hasReachedEnd = false, + this.currentPage = 1 + }); + + bool get hasError => error != null; + bool get isLoading => isCustomersLoading; + bool get hasCustomers => customers.isNotEmpty; + + CustomersPageState copyWith({ + bool? isCustomersLoading = false, + ErrorDetails? error, + List? customers, + bool? hasReachedEnd, + int? currentPage, + bool removeError = false, + bool removeCustomers = false + }) { + return CustomersPageState( + isCustomersLoading: isCustomersLoading ?? this.isCustomersLoading, + error: removeError ? null : error ?? this.error, + customers: removeCustomers ? const [] : customers ?? this.customers, + hasReachedEnd: hasReachedEnd ?? this.hasReachedEnd, + currentPage: currentPage ?? this.currentPage + ); + } + + @override + List get props => [ + isCustomersLoading, + error, + customers, + hasReachedEnd, + currentPage + ]; } \ No newline at end of file diff --git a/flutter/lib/src/features/customers/presentation/pages/create_customer_page.dart b/flutter/lib/src/features/customers/presentation/pages/create_customer_page.dart index 20873e8..98360c9 100644 --- a/flutter/lib/src/features/customers/presentation/pages/create_customer_page.dart +++ b/flutter/lib/src/features/customers/presentation/pages/create_customer_page.dart @@ -1,21 +1,32 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:habib_app/core/common/models/error_details.dart'; import 'package:habib_app/core/common/widgets/hb_dialog.dart'; import 'package:habib_app/core/common/widgets/hb_gap.dart'; -import 'package:habib_app/core/common/widgets/sc_text_field.dart'; +import 'package:habib_app/core/common/widgets/hb_text_field.dart'; +import 'package:habib_app/core/extensions/object_extension.dart'; import 'package:habib_app/core/res/hb_icons.dart'; +import 'package:habib_app/core/services/routes.dart'; +import 'package:habib_app/core/utils/core_utils.dart'; +import 'package:habib_app/core/utils/enums/toast_type.dart'; +import 'package:habib_app/core/utils/result.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/core/utils/validator.dart'; +import 'package:habib_app/src/features/customers/domain/usecases/customer_create_customer_usecase.dart'; -class CreateCustomerPage extends StatefulHookWidget { +class CreateCustomerPage extends StatefulHookConsumerWidget { const CreateCustomerPage({ super.key }); @override - State createState() => _CreateCustomerPageState(); + ConsumerState createState() => _CreateCustomerPageState(); } -class _CreateCustomerPageState extends State { +class _CreateCustomerPageState extends ConsumerState { late TextEditingController _firstNameController; late TextEditingController _lastNameController; @@ -23,6 +34,94 @@ class _CreateCustomerPageState extends State { late TextEditingController _occupationController; late TextEditingController _phoneController; late TextEditingController _mobileController; + late TextEditingController _postalCodeController; + late TextEditingController _cityController; + late TextEditingController _streetController; + + Future _handleCreate() async { + final String title = _titleController.text.trim(); + final String firstName = _firstNameController.text.trim(); + final String lastName = _lastNameController.text.trim(); + final String occupation = _occupationController.text.trim(); + final String phone = _phoneController.text.trim(); + final String mobile = _mobileController.text.trim(); + final String addressStreet = _streetController.text.trim(); + final String addressPostalCode = _postalCodeController.text.trim(); + final String addressCity = _cityController.text.trim(); + + try { + Validator.validateCustomerCreate( + title: title, + firstName: firstName, + lastName: lastName, + occupation: occupation, + phone: phone, + mobile: mobile, + addressStreet: addressStreet, + addressPostalCode: addressPostalCode, + addressCity: addressCity + ); + } catch (e) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: e.errorTitle, + description: e.errorDescription, + ); + return; + } + + Json addressJson = { + 'street' : "'$addressStreet'", + 'postal_code' : "'$addressPostalCode'", + 'city' : "'$addressCity'" + }; + + Json customerJson = { + if (title.isNotEmpty) 'title' : "'$title'", + 'first_name' : "'$firstName'", + 'last_name' : "'$lastName'", + if (occupation.isNotEmpty) 'occupation' : "'$occupation'", + if (phone.isNotEmpty) 'phone' : "'$phone'", + if (mobile.isNotEmpty) 'mobile' : "'$mobile'" + }; + + final CustomerCreateCustomerUsecase customerCreateCustomerUsecase = ref.read(customerCreateCustomerUsecaseProvider); + final CustomerCreateCustomerUsecaseParams customerCreateCustomerUsecaseParams = CustomerCreateCustomerUsecaseParams( + addressJson: addressJson, + customerJson: customerJson + ); + final Result customerCreateCustomerUsecaseResult = await customerCreateCustomerUsecase.call(customerCreateCustomerUsecaseParams); + + customerCreateCustomerUsecaseResult.fold( + onSuccess: (int customerId) async { + CoreUtils.showToast( + context, + type: ToastType.success, + title: 'Erfolgreich angelegt.', + description: 'Der Kunde wurde erfolgreich angelegt.' + ); + + if (!mounted) return; + + context.pop(); + + await CustomerDetailsRoute(customerId: customerId).push(context); + }, + onFailure: (Object error, StackTrace stackTrace) { + final ErrorDetails errorDetails = ErrorDetails( + error: error, + stackTrace: stackTrace + ); + CoreUtils.showToast( + context, + type: ToastType.error, + title: errorDetails.errorTitle, + description: errorDetails.errorDescription, + ); + } + ); + } @override Widget build(BuildContext context) { @@ -33,31 +132,37 @@ class _CreateCustomerPageState extends State { _occupationController = useTextEditingController(); _phoneController = useTextEditingController(); _mobileController = useTextEditingController(); + _postalCodeController = useTextEditingController(); + _cityController = useTextEditingController(); + _streetController = useTextEditingController(); return HBDialog( title: 'Neuer Kunde', actionButton: HBDialogActionButton( - onPressed: () {}, + onPressed: _handleCreate, title: 'Erstellen' ), children: [ - const HBDialogSection(title: 'Personendaten'), + const HBDialogSection( + title: 'Personendatails', + isFirstSection: true + ), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ Expanded( child: HBTextField( - controller: _firstNameController, - icon: HBIcons.user, - hint: 'Vorname' + controller: _titleController, + icon: HBIcons.academicCap, + title: 'Titel' ) ), const HBGap.lg(), Expanded( child: HBTextField( - controller: _lastNameController, + controller: _firstNameController, icon: HBIcons.user, - hint: 'Nachname' + title: 'Vorname' ) ) ] @@ -68,52 +173,78 @@ class _CreateCustomerPageState extends State { children: [ Expanded( child: HBTextField( - controller: _titleController, - icon: HBIcons.academicCap, - hint: 'Titel' + controller: _lastNameController, + icon: HBIcons.user, + title: 'Nachname' ) ), const HBGap.lg(), - const Spacer() + Expanded( + child: HBTextField( + controller: _occupationController, + icon: HBIcons.beaker, + title: 'Beruf' + ) + ) ] ), - const HBDialogSection(title: 'Beruf'), + const HBDialogSection(title: 'Kontaktinformationen'), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ Expanded( child: HBTextField( - controller: _occupationController, + controller: _phoneController, + icon: HBIcons.phone, + title: 'Telefon' + ) + ), + const HBGap.lg(), + Expanded( + child: HBTextField( + controller: _mobileController, + icon: HBIcons.phone, + title: 'Mobil' + ) + ) + ] + ), + const HBDialogSection(title: 'Adresse'), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Expanded( + child: HBTextField( + controller: _streetController, icon: HBIcons.beaker, - hint: 'Beruf' + title: 'Straße & Hausnummer' ) ), const HBGap.lg(), const Spacer() ] ), - const HBDialogSection(title: 'Kontakt'), + const HBGap.lg(), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ Expanded( child: HBTextField( - controller: _phoneController, - icon: HBIcons.phone, - hint: 'Telefon' + controller: _postalCodeController, + icon: HBIcons.user, + title: 'Postleitzahl' ) ), const HBGap.lg(), Expanded( child: HBTextField( - controller: _mobileController, - icon: HBIcons.phone, - hint: 'Mobil' + controller: _cityController, + icon: HBIcons.user, + title: 'Stadt' ) ) ] - ), - const HBDialogSection(title: 'Anschrift') + ) ] ); } diff --git a/flutter/lib/src/features/customers/presentation/pages/customer_details_page.dart b/flutter/lib/src/features/customers/presentation/pages/customer_details_page.dart index 0bb407d..ab61f4c 100644 --- a/flutter/lib/src/features/customers/presentation/pages/customer_details_page.dart +++ b/flutter/lib/src/features/customers/presentation/pages/customer_details_page.dart @@ -1,6 +1,32 @@ import 'package:flutter/material.dart'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:habib_app/src/features/customers/domain/entities/customer_details_address_entity.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/customers/domain/usecases/customer_update_customer_usecase.dart'; +import 'package:habib_app/core/utils/validator.dart'; +import 'package:habib_app/src/features/customers/domain/entities/customer_details_entity.dart'; +import 'package:habib_app/core/res/theme/colors/hb_colors.dart'; +import 'package:habib_app/core/res/theme/typography/hb_typography.dart'; +import 'package:habib_app/core/common/widgets/hb_gap.dart'; +import 'package:habib_app/core/common/widgets/hb_text_field.dart'; +import 'package:habib_app/core/extensions/context_extension.dart'; +import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; +import 'package:habib_app/core/common/models/error_details.dart'; +import 'package:habib_app/core/common/widgets/hb_message_box.dart'; +import 'package:habib_app/core/utils/result.dart'; +import 'package:habib_app/src/features/customers/domain/usecases/customer_delete_customer_usecase.dart'; +import 'package:habib_app/src/features/customers/presentation/widgets/customer_borrows_table.dart'; +import 'package:habib_app/core/extensions/object_extension.dart'; +import 'package:habib_app/src/features/customers/presentation/app/customer_details_page_notifier.dart'; +import 'package:habib_app/core/utils/enums/toast_type.dart'; import 'package:habib_app/core/common/widgets/hb_app_bar.dart'; import 'package:habib_app/core/common/widgets/hb_scaffold.dart'; +import 'package:habib_app/core/res/hb_icons.dart'; +import 'package:habib_app/core/utils/core_utils.dart'; class CustomerDetailsPageParams { @@ -11,7 +37,7 @@ class CustomerDetailsPageParams { }); } -class CustomerDetailsPage extends StatelessWidget { +class CustomerDetailsPage extends StatefulHookConsumerWidget { final CustomerDetailsPageParams params; @@ -20,13 +46,405 @@ class CustomerDetailsPage extends StatelessWidget { required this.params }); + @override + ConsumerState createState() => _CustomerDetailsPageState(); +} + +class _CustomerDetailsPageState extends ConsumerState { + + late TextEditingController _titleController; + late TextEditingController _firstNameController; + late TextEditingController _lastNameController; + late TextEditingController _occupationController; + late TextEditingController _phoneController; + late TextEditingController _mobileController; + late TextEditingController _streetController; + late TextEditingController _postalCodeController; + late TextEditingController _cityController; + + void _onDetailsStateUpdate(CustomerDetailsPageState? _, CustomerDetailsPageState next) { + if (next.hasError) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: next.error!.errorTitle, + description: next.error!.errorDescription, + ); + } + } + + Future _onRefresh() async { + ref.read(customerDetailsPageNotifierProvider(widget.params.customerId).notifier).fetch(); + } + + Future _onSave() async { + final CustomerDetailsPageState pageState = ref.watch(customerDetailsPageNotifierProvider(widget.params.customerId)); + final CustomerDetailsEntity customer = pageState.customer!; + + final String title = _titleController.text.trim(); + final String firstName = _firstNameController.text.trim(); + final String lastName = _lastNameController.text.trim(); + final String occupation = _occupationController.text.trim(); + final String phone = _phoneController.text.trim(); + final String mobile = _mobileController.text.trim(); + final String addressStreet = _streetController.text.trim(); + final String addressPostalCode = _postalCodeController.text.trim(); + final String addressCity = _cityController.text.trim(); + + try { + Validator.validateCustomerUpdate( + title: title, + firstName: firstName, + lastName: lastName, + occupation: occupation, + phone: phone, + mobile: mobile, + addressStreet: addressStreet, + addressPostalCode: addressPostalCode, + addressCity: addressCity + ); + } catch (e) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: e.errorTitle, + description: e.errorDescription, + ); + return; + } + + final bool replaceTitle = title != (customer.title ?? ''); + final bool replaceFirstName = firstName != customer.firstName; + final bool replaceLastName = lastName != customer.lastName; + final bool replaceOccupation = occupation != (customer.occupation ?? ''); + final bool replacePhone = phone != (customer.phone ?? ''); + final bool replaceMobile = mobile != (customer.mobile ?? ''); + final bool replaceAddressStreet = addressStreet != customer.address.street; + final bool replaceAddressPostalCode = addressPostalCode != customer.address.postalCode; + final bool replaceAddressCity = addressCity != customer.address.city; + + Json customerJson = { + if (replaceTitle) 'title' : "'$title'", + if (replaceFirstName) 'first_name' : "'$firstName'", + if (replaceLastName) 'last_name' : "'$lastName'", + if (replaceOccupation) 'occupation' : "'$occupation'", + if (replacePhone) 'phone' : "'$phone'", + if (replaceMobile) 'mobile' : "'$mobile'" + }; + + Json addressJson = { + if (replaceAddressStreet) 'street' : "'$addressStreet'", + if (replaceAddressPostalCode) 'postal_code' : "'$addressPostalCode'", + if (replaceAddressCity) 'city' : "'$addressCity'" + }; + + if (customerJson.isEmpty && addressJson.isEmpty) return; + + final CustomerUpdateCustomerUsecase customerUpdateCustomerUsecase = ref.read(customerUpdateCustomerUsecaseProvider); + final CustomerUpdateCustomerUsecaseParams customerUpdateCustomerUsecaseParams = CustomerUpdateCustomerUsecaseParams( + customerId: customer.id, + customerJson: customerJson, + addressId: customer.address.id, + addressJson: addressJson + ); + final Result customerUpdateCustomerUsecaseResult = await customerUpdateCustomerUsecase.call(customerUpdateCustomerUsecaseParams); + + customerUpdateCustomerUsecaseResult.fold( + onSuccess: (void _) { + ref.read(customerDetailsPageNotifierProvider(widget.params.customerId).notifier).replace( + CustomerDetailsEntity( + id: customer.id, + firstName: replaceFirstName ? firstName : customer.firstName, + lastName: replaceLastName ? lastName : customer.lastName, + occupation: replaceOccupation ? occupation : customer.occupation, + phone: replacePhone ? phone : customer.phone, + mobile: replaceMobile ? mobile : customer.mobile, + address: CustomerDetailsAddressEntity( + id: customer.address.id, + street: replaceAddressStreet ? addressStreet : customer.address.street, + postalCode: replaceAddressPostalCode ? addressPostalCode : customer.address.postalCode, + city: replaceAddressCity ? addressCity : customer.address.city + ) + ) + ); + + CoreUtils.showToast( + context, + type: ToastType.success, + title: 'Erfolgreich aktualisiert.', + description: 'Der Kunde wurde erfolgreich aktualisiert.' + ); + }, + onFailure: (Object error, StackTrace stackTrace) { + final ErrorDetails errorDetails = ErrorDetails( + error: error, + stackTrace: stackTrace + ); + CoreUtils.showToast( + context, + type: ToastType.error, + title: errorDetails.errorTitle, + description: errorDetails.errorDescription, + ); + } + ); + } + + Future _onDelete() async { + final bool? success = await showHBMessageBox( + context, + 'Diesen Kunden wirklich löschen?', + 'Wenn Sie diesen Kunden löschen, werden alle damit verbundenen Daten ebenfalls gelöscht. Dieser Vorgang kann nicht rückgängig gemacht werden.', + 'Löschen', + onPressed: () async { + final CustomerDeleteCustomerUsecase customerDeleteCustomerUsecase = ref.read(customerDeleteCustomerUsecaseProvider); + final CustomerDeleteCustomerUsecaseParams customerDeleteCustomerUsecaseParams = CustomerDeleteCustomerUsecaseParams(customerId: widget.params.customerId); + final Result customerDeleteCustomerUsecaseResult = await customerDeleteCustomerUsecase.call(customerDeleteCustomerUsecaseParams); + + return customerDeleteCustomerUsecaseResult.fold( + onSuccess: (void _) => true, + onFailure: (Object error, StackTrace stackTrace) { + final ErrorDetails errorDetails = ErrorDetails( + error: error, + stackTrace: stackTrace + ); + CoreUtils.showToast( + context, + type: ToastType.error, + title: errorDetails.errorTitle, + description: errorDetails.errorDescription, + ); + return false; + } + ); + } + ); + + if (mounted && context.canPop() && (success ?? false)) context.pop(true); + } + + @override + void initState() { + super.initState(); + + CoreUtils.postFrameCall(() { + ref.read(customerDetailsPageNotifierProvider(widget.params.customerId).notifier).fetch(); + }); + } + @override Widget build(BuildContext context) { + + final CustomerDetailsPageState pageState = ref.watch(customerDetailsPageNotifierProvider(widget.params.customerId)); + + if (pageState.hasCustomer) { + _titleController = useTextEditingController(text: pageState.customer!.title); + _firstNameController = useTextEditingController(text: pageState.customer!.firstName); + _lastNameController = useTextEditingController(text: pageState.customer!.lastName); + _occupationController = useTextEditingController(text: pageState.customer!.occupation); + _phoneController = useTextEditingController(text: pageState.customer!.phone); + _mobileController = useTextEditingController(text: pageState.customer!.mobile); + _streetController = useTextEditingController(text: pageState.customer!.address.street); + _postalCodeController = useTextEditingController(text: pageState.customer!.address.postalCode); + _cityController = useTextEditingController(text: pageState.customer!.address.city); + } + + ref.listen( + customerDetailsPageNotifierProvider(widget.params.customerId), + _onDetailsStateUpdate + ); + return HBScaffold( appBar: HBAppBar( - context: context, + context: context, title: 'Details', - backButton: const HBAppBarBackButton() + backButton: const HBAppBarBackButton(), + actionButtons: [ + HBAppBarButton( + onPressed: _onRefresh, + icon: HBIcons.arrowPath, + isEnabled: !pageState.isLoading + ), + HBAppBarButton( + onPressed: _onSave, + icon: HBIcons.cloudArrowUp, + isEnabled: pageState.hasCustomer, + ), + HBAppBarButton( + onPressed: _onDelete, + icon: HBIcons.trash, + isEnabled: pageState.hasCustomer + ) + ] + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: pageState.hasCustomer + ? SingleChildScrollView( + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + top: HBSpacing.lg + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Personendetails', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 20.0, + fontWeight: FontWeight.w600, + color: HBColors.gray900 + ) + ), + const HBGap.xl(), + Row( + children: [ + Expanded( + child: HBTextField( + title: 'Titel', + controller: _titleController, + icon: HBIcons.home + ) + ), + const HBGap.xl(), + Expanded( + child: HBTextField( + title: 'Vorname', + controller: _firstNameController, + icon: HBIcons.hashtag + ) + ), + const HBGap.xl(), + Expanded( + child: HBTextField( + title: 'Nachname', + controller: _lastNameController, + icon: HBIcons.hashtag + ) + ) + ] + ), + const HBGap.xl(), + Row( + children: [ + Expanded( + child: HBTextField( + title: 'Beruf', + controller: _occupationController, + icon: HBIcons.home + ) + ), + const HBGap.xl(), + const Spacer(), + const HBGap.xl(), + const Spacer() + ] + ), + const HBGap.xxl(), + Text( + 'Kontaktinformationen', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 20.0, + fontWeight: FontWeight.w600, + color: HBColors.gray900 + ) + ), + const HBGap.xl(), + Row( + children: [ + Expanded( + child: HBTextField( + title: 'Telefon', + controller: _phoneController, + icon: HBIcons.home, + inputType: TextInputType.phone + ) + ), + const HBGap.xl(), + Expanded( + child: HBTextField( + title: 'Mobil', + controller: _mobileController, + icon: HBIcons.hashtag, + inputType: TextInputType.phone + ) + ), + const HBGap.xl(), + const Spacer() + ] + ), + const HBGap.xxl(), + Text( + 'Adresse', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 20.0, + fontWeight: FontWeight.w600, + color: HBColors.gray900 + ) + ), + const HBGap.xl(), + Row( + children: [ + Expanded( + child: HBTextField( + title: 'Straße & Hausnummer', + controller: _streetController, + icon: HBIcons.home, + inputType: TextInputType.streetAddress + ) + ), + const HBGap.xl(), + Expanded( + child: HBTextField( + title: 'Postleitzahl', + controller: _postalCodeController, + icon: HBIcons.hashtag, + inputType: TextInputType.number + ) + ), + const HBGap.xl(), + Expanded( + child: HBTextField( + title: 'Stadt', + controller: _cityController, + icon: HBIcons.hashtag + ) + ) + ] + ) + ] + ) + ) + : pageState.hasError + ? Center( + child: Text( + pageState.error!.errorDescription, + textAlign: TextAlign.center, + style: HBTypography.base.copyWith( + fontSize: 14.0, + fontWeight: FontWeight.w600, + color: HBColors.gray900 + ), + ) + ) + : const SizedBox.shrink() + ), + const Divider(), + Expanded( + flex: 2, + child: CustomerBorrowsTable(customerId: widget.params.customerId) + ) + ] ) ); } diff --git a/flutter/lib/src/features/customers/presentation/pages/customers_page.dart b/flutter/lib/src/features/customers/presentation/pages/customers_page.dart index 8f84100..4e46fb9 100644 --- a/flutter/lib/src/features/customers/presentation/pages/customers_page.dart +++ b/flutter/lib/src/features/customers/presentation/pages/customers_page.dart @@ -3,13 +3,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:habib_app/core/extensions/object_extension.dart'; import 'package:habib_app/core/common/widgets/hb_table.dart'; -import 'package:habib_app/core/extensions/exception_extension.dart'; import 'package:habib_app/core/common/widgets/hb_app_bar.dart'; import 'package:habib_app/core/common/widgets/hb_button.dart'; import 'package:habib_app/core/common/widgets/hb_gap.dart'; import 'package:habib_app/core/common/widgets/hb_scaffold.dart'; -import 'package:habib_app/core/common/widgets/sc_text_field.dart'; +import 'package:habib_app/core/common/widgets/hb_text_field.dart'; import 'package:habib_app/core/extensions/context_extension.dart'; import 'package:habib_app/core/res/hb_icons.dart'; import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; @@ -35,12 +35,12 @@ class _CustomersPageState extends ConsumerState { late TextEditingController _searchController; void _onPageStateUpdate(CustomersPageState? _, CustomersPageState next) { - if (next.exception != null) { + if (next.hasError) { CoreUtils.showToast( context, type: ToastType.error, - title: next.exception!.title(context), - description: next.exception!.description(context) + title: next.error!.errorTitle, + description: next.error!.errorDescription, ); } } @@ -60,15 +60,15 @@ class _CustomersPageState extends ConsumerState { HBTableStatus get _tableStatus { final CustomersPageState pageState = ref.read(customersPageNotifierProvider); - if (pageState.status == CustomersPageStatus.success && pageState.customers.isNotEmpty) return HBTableStatus.data; - if (pageState.status == CustomersPageStatus.failure || (pageState.status == CustomersPageStatus.success && pageState.customers.isEmpty)) return HBTableStatus.text; + if (pageState.hasCustomers) return HBTableStatus.data; + if (pageState.hasError || !pageState.hasCustomers) return HBTableStatus.text; return HBTableStatus.loading; } String? get _tableText { final CustomersPageState pageState = ref.read(customersPageNotifierProvider); - if (pageState.status == CustomersPageStatus.success && pageState.customers.isEmpty) return 'Keine Kunden gefunden.'; - if (pageState.status == CustomersPageStatus.failure) return 'Ein Fehler ist aufgetreten.'; + if (!pageState.isLoading && !pageState.hasError && !pageState.hasCustomers) return 'Keine Kund*innen gefunden.'; + if (pageState.hasError) return 'Ein Fehler ist aufgetreten.'; return null; } @@ -77,14 +77,15 @@ class _CustomersPageState extends ConsumerState { } Future _onCustomerPressed(int customerId) async { - await CustomerDetailsRoute(customerId: customerId).push(context); + final bool? customerDeleted = await CustomerDetailsRoute(customerId: customerId).push(context); + if (customerDeleted ?? false) ref.read(customersPageNotifierProvider.notifier).refresh(_searchText); } Future _onCreateCustomer() async { await const CreateCustomerRoute().push(context); } - Future _onSearchChanged() async { + Future _onSearchChanged(String _) async { await ref.read(customersPageNotifierProvider.notifier).refresh(_searchText); } @@ -141,7 +142,7 @@ class _CustomersPageState extends ConsumerState { children: [ HBTextField( controller: _searchController, - onChanged: (String _) => _onSearchChanged, + onChanged: _onSearchChanged, icon: HBIcons.magnifyingGlass, hint: 'Name', maxWidth: 500.0 diff --git a/flutter/lib/src/features/customers/presentation/widgets/customer_borrows_table.dart b/flutter/lib/src/features/customers/presentation/widgets/customer_borrows_table.dart new file mode 100644 index 0000000..678f436 --- /dev/null +++ b/flutter/lib/src/features/customers/presentation/widgets/customer_borrows_table.dart @@ -0,0 +1,236 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:habib_app/src/features/borrows/presentation/pages/create_borrow_page.dart'; +import 'package:habib_app/src/features/customers/presentation/app/customer_details_page_notifier.dart'; +import 'package:habib_app/core/extensions/object_extension.dart'; +import 'package:habib_app/core/common/widgets/hb_button.dart'; +import 'package:habib_app/core/common/widgets/hb_chip.dart'; +import 'package:habib_app/core/common/widgets/hb_gap.dart'; +import 'package:habib_app/core/common/widgets/hb_table.dart'; +import 'package:habib_app/core/common/widgets/hb_text_field.dart'; +import 'package:habib_app/core/extensions/context_extension.dart'; +import 'package:habib_app/core/extensions/datetime_extension.dart'; +import 'package:habib_app/core/res/hb_icons.dart'; +import 'package:habib_app/core/res/theme/colors/hb_colors.dart'; +import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; +import 'package:habib_app/core/res/theme/typography/hb_typography.dart'; +import 'package:habib_app/core/services/routes.dart'; +import 'package:habib_app/core/utils/constants/hb_ui_constants.dart'; +import 'package:habib_app/core/utils/core_utils.dart'; +import 'package:habib_app/core/utils/enums/toast_type.dart'; +import 'package:habib_app/src/features/customers/domain/entities/customer_borrow_entity.dart'; +import 'package:habib_app/src/features/customers/presentation/app/customer_borrows_notifier.dart'; + +class CustomerBorrowsTable extends StatefulHookConsumerWidget { + + final int customerId; + + const CustomerBorrowsTable({ + super.key, + required this.customerId + }); + + @override + ConsumerState createState() => _CustomerBorrowsTableState(); +} + +class _CustomerBorrowsTableState extends ConsumerState { + + final ScrollController _scrollController = ScrollController(); + + late TextEditingController _searchController; + + void _onBorrowsStateUpdate(CustomerBorrowsState? _, CustomerBorrowsState next) { + if (next.hasError) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: next.error!.errorTitle, + description: next.error!.errorDescription, + ); + } + } + + Future _onBorrowPressed(int borrowId) async { + await BorrowDetailsRoute(borrowId: borrowId).push(context); + } + + Future _onSearchChanged(String _) async { + await ref.read(customerBorrowsNotifierProvider(widget.customerId).notifier).refresh(_searchText); + } + + Future _onRefreshBorrows() async { + await ref.read(customerBorrowsNotifierProvider(widget.customerId).notifier).refresh(_searchText); + } + + Future _onBookPressed(int bookId) async { + await BookDetailsRoute(bookId: bookId).push(context); + } + + Future _onNewBorrow() async { + final CustomerDetailsPageState pageState = ref.read(customerDetailsPageNotifierProvider(widget.customerId)); + if (!pageState.hasCustomer) return; + final CreateBorrowCustomer customer = CreateBorrowCustomer( + id: pageState.customer!.id, + title: pageState.customer!.title, + firstName: pageState.customer!.firstName, + lastName: pageState.customer!.lastName + ); + final CreateBorrowPageParams params = CreateBorrowPageParams(customer: customer); + await CreateBorrowRoute($extra: params).push(context); + } + + bool get _isBottom { + if (!_scrollController.hasClients) return false; + final double maxScroll = _scrollController.position.maxScrollExtent; + final double currentScroll = _scrollController.offset; + return currentScroll >= maxScroll * 0.9; + } + + void _onScroll() { + if (_isBottom) { + ref.read(customerBorrowsNotifierProvider(widget.customerId).notifier).fetchNextPage(_searchText); + } + } + + String get _searchText { + return _searchController.text.trim(); + } + + HBTableStatus get _tableStatus { + final CustomerBorrowsState pageState = ref.read(customerBorrowsNotifierProvider(widget.customerId)); + if (pageState.hasCustomerBorrows) return HBTableStatus.data; + if (pageState.hasError || !pageState.hasCustomerBorrows) return HBTableStatus.text; + return HBTableStatus.loading; + } + + String? get _tableText { + final CustomerBorrowsState pageState = ref.read(customerBorrowsNotifierProvider(widget.customerId)); + if (!pageState.isLoading && !pageState.hasError && !pageState.hasCustomerBorrows) return 'Keine Ausleihen gefunden.'; + if (pageState.hasError) return 'Ein Fehler ist aufgetreten.'; + return null; + } + + @override + void initState() { + super.initState(); + + _scrollController.addListener(_onScroll); + + CoreUtils.postFrameCall(() { + ref.read(customerBorrowsNotifierProvider(widget.customerId).notifier).fetchNextPage(_searchText); + }); + } + + @override + void dispose() { + _scrollController + ..removeListener(_onScroll) + ..dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + + final CustomerBorrowsState borrowsState = ref.watch(customerBorrowsNotifierProvider(widget.customerId)); + + _searchController = useTextEditingController(); + + ref.listen( + customerBorrowsNotifierProvider(widget.customerId), + _onBorrowsStateUpdate + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + top: HBSpacing.lg + ), + child: Text( + 'Ausleihen', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontWeight: FontWeight.w600, + fontSize: 20.0, + color: HBColors.gray900 + ) + ) + ), + Padding( + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + top: HBSpacing.lg + ), + child: Row( + children: [ + HBTextField( + controller: _searchController, + onChanged: _onSearchChanged, + icon: HBIcons.magnifyingGlass, + hint: 'Buchtitel', + maxWidth: 500.0 + ), + const HBGap.lg(), + const Spacer(), + HBButton.shrinkFill( + onPressed: _onNewBorrow, + icon: HBIcons.plus, + title: 'Neue Ausleihe' + ), + const HBGap.md(), + HBButton.shrinkFill( + onPressed: _onRefreshBorrows, + icon: HBIcons.arrowPath + ) + ] + ) + ), + Expanded( + child: HBTable( + onPressed: (int index) => _onBorrowPressed(borrowsState.customerBorrows[index].id), + status: _tableStatus, + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + bottom: context.bottomPadding + HBSpacing.lg, + top: HBSpacing.xxl + ), + controller: _scrollController, + tableWidth: context.width - HBUIConstants.navigationRailWidth - context.rightPadding - 4.0 * HBSpacing.lg, + columnLength: 3, + fractions: const [ 0.7, 0.15, 0.15 ], + titles: const [ 'Buchtitel (Auflage)', 'Rückgabedatum', 'Status' ], + items: List.generate(borrowsState.customerBorrows.length, (int index) { + final CustomerBorrowEntity borrow = borrowsState.customerBorrows[index]; + return [ + HBTableText( + onPressed: () => _onBookPressed(borrow.book.id), + text: '${ borrow.book.title }${ borrow.book.edition != null ? ' (${ borrow.book.edition }. Auflage)' : '' }' + ), + HBTableText(text: borrow.endDate.toHumanReadableDate()), + HBTableChip( + chip: HBChip( + text: borrow.status.title, + color: borrow.status.color + ) + ) + ]; + }), + text: _tableText + ) + ) + ] + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/customers/presentation/widgets/customers_selection_dialog.dart b/flutter/lib/src/features/customers/presentation/widgets/customers_selection_dialog.dart new file mode 100644 index 0000000..2efd719 --- /dev/null +++ b/flutter/lib/src/features/customers/presentation/widgets/customers_selection_dialog.dart @@ -0,0 +1,231 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:habib_app/src/features/customers/domain/entities/customer_entity.dart'; +import 'package:habib_app/src/features/customers/presentation/app/customers_page_notifier.dart'; +import 'package:habib_app/src/features/borrows/presentation/pages/create_borrow_page.dart'; +import 'package:habib_app/core/extensions/object_extension.dart'; +import 'package:habib_app/core/common/widgets/hb_button.dart'; +import 'package:habib_app/core/common/widgets/hb_gap.dart'; +import 'package:habib_app/core/common/widgets/hb_selection_dialog.dart'; +import 'package:habib_app/core/common/widgets/hb_table.dart'; +import 'package:habib_app/core/common/widgets/hb_text_field.dart'; +import 'package:habib_app/core/extensions/context_extension.dart'; +import 'package:habib_app/core/res/hb_icons.dart'; +import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; +import 'package:habib_app/core/utils/core_utils.dart'; +import 'package:habib_app/core/utils/enums/toast_type.dart'; + +Future showCustomersSelectionDialog({ + required BuildContext context, + required CreateBorrowCustomer? customer +}) async { + return await showHBSelectionDialog( + context: context, + title: 'Kund*innen wählen', + content: CustomersSelectionDialog(customer: customer) + ); +} + +class CustomersSelectionDialog extends StatefulHookConsumerWidget { + + final CreateBorrowCustomer? customer; + + const CustomersSelectionDialog({ + super.key, + this.customer + }); + + @override + ConsumerState createState() => _CustomersSelectionDialogState(); +} + +class _CustomersSelectionDialogState extends ConsumerState { + + late ValueNotifier _customerNotifier; + + final ScrollController _scrollController = ScrollController(); + + late TextEditingController _searchController; + + void _onPageStateUpdate(CustomersPageState? _, CustomersPageState next) { + if (next.hasError) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: next.error!.errorTitle, + description: next.error!.errorDescription, + ); + } + } + + bool get _isBottom { + if (!_scrollController.hasClients) return false; + final double maxScroll = _scrollController.position.maxScrollExtent; + final double currentScroll = _scrollController.offset; + return currentScroll >= maxScroll * 0.9; + } + + void _onScroll() { + if (_isBottom) { + ref.read(customersPageNotifierProvider.notifier).fetchNextPage(_searchText); + } + } + + HBTableStatus get _tableStatus { + final CustomersPageState pageState = ref.read(customersPageNotifierProvider); + if (pageState.hasCustomers) return HBTableStatus.data; + if (pageState.hasError || !pageState.hasCustomers) return HBTableStatus.text; + return HBTableStatus.loading; + } + + String? get _tableText { + final CustomersPageState pageState = ref.read(customersPageNotifierProvider); + if (!pageState.isLoading && !pageState.hasError && !pageState.hasCustomers) return 'Keine Kund*innen gefunden.'; + if (pageState.hasError) return 'Ein Fehler ist aufgetreten.'; + return null; + } + + String get _searchText { + return _searchController.text.trim(); + } + + Future _onSearchChanged(String _) async { + await ref.read(customersPageNotifierProvider.notifier).refresh(_searchText); + } + + Future _onRefresh() async { + await ref.read(customersPageNotifierProvider.notifier).refresh(_searchText); + } + + void _onRowPressed(CustomerEntity selectedCustomer) async { + final CreateBorrowCustomer newCustomer = CreateBorrowCustomer( + id: selectedCustomer.id, + title: selectedCustomer.title, + firstName: selectedCustomer.firstName, + lastName: selectedCustomer.lastName + ); + _customerNotifier.value = newCustomer; + } + + void _cancel() { + context.pop(); + } + + void _onChoose() { + context.pop(_customerNotifier.value); + } + + @override + void initState() { + super.initState(); + + _scrollController.addListener(_onScroll); + + CoreUtils.postFrameCall(() { + ref.read(customersPageNotifierProvider.notifier).fetchNextPage(_searchText); + }); + } + + @override + void dispose() { + _scrollController + ..removeListener(_onScroll) + ..dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + + _customerNotifier = useState(widget.customer); + + final CustomersPageState pageState = ref.watch(customersPageNotifierProvider); + + _searchController = useTextEditingController(); + + ref.listen( + customersPageNotifierProvider, + _onPageStateUpdate + ); + + return Column( + children: [ + Padding( + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + top: HBSpacing.lg + ), + child: Row( + children: [ + Expanded( + child: HBTextField( + controller: _searchController, + onChanged: _onSearchChanged, + icon: HBIcons.magnifyingGlass, + hint: 'Name' + ) + ), + const HBGap.lg(), + HBButton.shrinkFill( + onPressed: _onRefresh, + icon: HBIcons.arrowPath + ) + ] + ) + ), + Expanded( + child: HBTable( + onPressed: (int index) => _onRowPressed(pageState.customers[index]), + status: _tableStatus, + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + bottom: context.bottomPadding + HBSpacing.lg, + top: HBSpacing.xxl + ), + controller: _scrollController, + tableWidth: 800.0 - 4.0 * HBSpacing.lg, + columnLength: 4, + fractions: const [ 0.35, 0.3, 0.3, 0.05 ], + titles: const [ 'Name', 'Telefon / Mobil', 'Straße', '' ], + items: List.generate(pageState.customers.length, (int index) { + final CustomerEntity customer = pageState.customers[index]; + final bool isSelected = _customerNotifier.value?.id == customer.id; + return [ + HBTableText(text: '${ customer.title != null ? '${ customer.title } ' : '' }${ customer.firstName } ${ customer.lastName }'), + HBTableText(text: '${ customer.phone ?? '' }${ customer.phone != null && customer.mobile != null ? ' | ' : '' }${ customer.mobile ?? '' }'), + HBTableText(text: '${ customer.address.street }, ${ customer.address.postalCode } ${ customer.address.city }'), + HBTableRadioIndicator(isSelected: isSelected) + ]; + }), + text: _tableText + ) + ), + const HBGap.lg(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: HBSpacing.lg), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + HBButton.shrinkFill( + onPressed: _onChoose, + title: 'Wählen' + ), + const HBGap.md(), + HBButton.shrinkOutline( + onPressed: _cancel, + title: 'Abbrechen' + ) + ] + ) + ) + ] + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/home/data/datasources/home_datasource.dart b/flutter/lib/src/features/home/data/datasources/home_datasource.dart new file mode 100644 index 0000000..02ef661 --- /dev/null +++ b/flutter/lib/src/features/home/data/datasources/home_datasource.dart @@ -0,0 +1,21 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/home/data/datasources/home_datasource_impl.dart'; +import 'package:habib_app/src/features/home/data/dto/stats_dto.dart'; +import 'package:habib_app/core/services/database.dart'; + +part 'home_datasource.g.dart'; + +@riverpod +HomeDatasource homeDatasource(HomeDatasourceRef ref) { + return HomeDatasourceImpl( + database: ref.read(databaseProvider) + ); +} + +abstract interface class HomeDatasource { + + const HomeDatasource(); + + Future getStats({ required int year }); +} \ No newline at end of file diff --git a/flutter/lib/src/features/home/data/datasources/home_datasource_impl.dart b/flutter/lib/src/features/home/data/datasources/home_datasource_impl.dart new file mode 100644 index 0000000..c78e246 --- /dev/null +++ b/flutter/lib/src/features/home/data/datasources/home_datasource_impl.dart @@ -0,0 +1,19 @@ +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/core/services/database.dart'; +import 'package:habib_app/src/features/home/data/datasources/home_datasource.dart'; +import 'package:habib_app/src/features/home/data/dto/stats_dto.dart'; + +class HomeDatasourceImpl implements HomeDatasource { + + final Database _database; + + const HomeDatasourceImpl({ + required Database database + }) : _database = database; + + @override + Future getStats({ required int year }) async { + final Json json = await _database.getStatisticsForYear(year); + return StatsDto.fromJson(json); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/home/data/dto/new_books_bought_dto.dart b/flutter/lib/src/features/home/data/dto/new_books_bought_dto.dart new file mode 100644 index 0000000..90f52fc --- /dev/null +++ b/flutter/lib/src/features/home/data/dto/new_books_bought_dto.dart @@ -0,0 +1,23 @@ +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/home/domain/entities/new_books_bought_entity.dart'; + +class NewBooksBoughtDto extends NewBooksBoughtEntity { + + const NewBooksBoughtDto({ + required super.month, + required super.boughtCount, + required super.notBoughtCount + }); + + factory NewBooksBoughtDto.fromJson(Json newbooksBoughtJson) { + return NewBooksBoughtDto( + month: newbooksBoughtJson['month'] as int, + boughtCount: newbooksBoughtJson['bought_count'] as int, + notBoughtCount: newbooksBoughtJson['not_bought_count'] as int + ); + } + + static List listFromJsonList(List jsonList) { + return jsonList.map((Json json) => NewBooksBoughtDto.fromJson(json)).toList(); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/home/data/dto/number_borrowed_books_dto.dart b/flutter/lib/src/features/home/data/dto/number_borrowed_books_dto.dart new file mode 100644 index 0000000..171908b --- /dev/null +++ b/flutter/lib/src/features/home/data/dto/number_borrowed_books_dto.dart @@ -0,0 +1,21 @@ +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/home/domain/entities/number_borrowed_books_entity.dart'; + +class NumberBorrowedBooksDto extends NumberBorrowedBooksEntity { + + const NumberBorrowedBooksDto({ + required super.month, + required super.booksCount + }); + + factory NumberBorrowedBooksDto.fromJson(Json numberBorrowedBooksJson) { + return NumberBorrowedBooksDto( + month: numberBorrowedBooksJson['month'] as int, + booksCount: numberBorrowedBooksJson['books_count'] as int + ); + } + + static List listFromJsonList(List jsonList) { + return jsonList.map((Json json) => NumberBorrowedBooksDto.fromJson(json)).toList(); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/home/data/dto/stats_dto.dart b/flutter/lib/src/features/home/data/dto/stats_dto.dart new file mode 100644 index 0000000..726ae2a --- /dev/null +++ b/flutter/lib/src/features/home/data/dto/stats_dto.dart @@ -0,0 +1,21 @@ +import 'dart:convert'; + +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/home/data/dto/new_books_bought_dto.dart'; +import 'package:habib_app/src/features/home/data/dto/number_borrowed_books_dto.dart'; +import 'package:habib_app/src/features/home/domain/entities/stats_entity.dart'; + +class StatsDto extends StatsEntity { + + const StatsDto({ + required super.numberBorrowedBooksList, + required super.newBooksBoughtList + }); + + factory StatsDto.fromJson(Json statsJson) { + return StatsDto( + numberBorrowedBooksList: List.from(json.decode(statsJson['number_borrowed_books'] as String)).map((Json numberBorrowedBooksListJson) => NumberBorrowedBooksDto.fromJson(numberBorrowedBooksListJson)).toList(), + newBooksBoughtList: List.from(json.decode(statsJson['new_books_bought'] as String)).map((Json newBooksBoughtListJson) => NewBooksBoughtDto.fromJson(newBooksBoughtListJson)).toList(), + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/home/data/repositories/home_repository_impl.dart b/flutter/lib/src/features/home/data/repositories/home_repository_impl.dart new file mode 100644 index 0000000..ea42971 --- /dev/null +++ b/flutter/lib/src/features/home/data/repositories/home_repository_impl.dart @@ -0,0 +1,25 @@ +import 'package:habib_app/core/utils/result.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/home/data/datasources/home_datasource.dart'; +import 'package:habib_app/src/features/home/data/dto/stats_dto.dart'; +import 'package:habib_app/src/features/home/domain/entities/stats_entity.dart'; +import 'package:habib_app/src/features/home/domain/repositories/home_repository.dart'; + +class HomeRepositoryImpl implements HomeRepository { + + final HomeDatasource _homeDatasource; + + const HomeRepositoryImpl({ + required HomeDatasource homeDatasource + }) : _homeDatasource = homeDatasource; + + @override + ResultFuture getStats({ required int year }) async { + try { + final StatsDto result = await _homeDatasource.getStats(year: year); + return Success(result); + } catch (e) { + return Failure(e); + } + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/home/domain/entities/new_books_bought_entity.dart b/flutter/lib/src/features/home/domain/entities/new_books_bought_entity.dart new file mode 100644 index 0000000..e74f303 --- /dev/null +++ b/flutter/lib/src/features/home/domain/entities/new_books_bought_entity.dart @@ -0,0 +1,21 @@ +import 'package:equatable/equatable.dart'; + +class NewBooksBoughtEntity extends Equatable { + + final int month; + final int boughtCount; + final int notBoughtCount; + + const NewBooksBoughtEntity({ + required this.month, + required this.boughtCount, + required this.notBoughtCount + }); + + @override + List get props => [ + month, + boughtCount, + notBoughtCount + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/home/domain/entities/number_borrowed_books_entity.dart b/flutter/lib/src/features/home/domain/entities/number_borrowed_books_entity.dart new file mode 100644 index 0000000..d7a266f --- /dev/null +++ b/flutter/lib/src/features/home/domain/entities/number_borrowed_books_entity.dart @@ -0,0 +1,18 @@ +import 'package:equatable/equatable.dart'; + +class NumberBorrowedBooksEntity extends Equatable { + + final int month; + final int booksCount; + + const NumberBorrowedBooksEntity({ + required this.month, + required this.booksCount + }); + + @override + List get props => [ + month, + booksCount + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/home/domain/entities/stats_entity.dart b/flutter/lib/src/features/home/domain/entities/stats_entity.dart new file mode 100644 index 0000000..c53c44e --- /dev/null +++ b/flutter/lib/src/features/home/domain/entities/stats_entity.dart @@ -0,0 +1,21 @@ +import 'package:equatable/equatable.dart'; + +import 'package:habib_app/src/features/home/domain/entities/new_books_bought_entity.dart'; +import 'package:habib_app/src/features/home/domain/entities/number_borrowed_books_entity.dart'; + +class StatsEntity extends Equatable { + + final List numberBorrowedBooksList; + final List newBooksBoughtList; + + const StatsEntity({ + required this.numberBorrowedBooksList, + required this.newBooksBoughtList + }); + + @override + List get props => [ + numberBorrowedBooksList, + newBooksBoughtList + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/home/domain/repositories/home_repository.dart b/flutter/lib/src/features/home/domain/repositories/home_repository.dart new file mode 100644 index 0000000..8e59e6a --- /dev/null +++ b/flutter/lib/src/features/home/domain/repositories/home_repository.dart @@ -0,0 +1,20 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/home/data/repositories/home_repository_impl.dart'; +import 'package:habib_app/src/features/home/domain/entities/stats_entity.dart'; +import 'package:habib_app/src/features/home/data/datasources/home_datasource.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'home_repository.g.dart'; + +@riverpod +HomeRepository homeRepository(HomeRepositoryRef ref) { + return HomeRepositoryImpl( + homeDatasource: ref.read(homeDatasourceProvider) + ); +} + +abstract interface class HomeRepository { + + ResultFuture getStats({ required int year }); +} \ No newline at end of file diff --git a/flutter/lib/src/features/home/domain/usecases/home_get_stats_usecase.dart b/flutter/lib/src/features/home/domain/usecases/home_get_stats_usecase.dart new file mode 100644 index 0000000..60f4e17 --- /dev/null +++ b/flutter/lib/src/features/home/domain/usecases/home_get_stats_usecase.dart @@ -0,0 +1,44 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/home/domain/entities/stats_entity.dart'; +import 'package:habib_app/src/features/home/domain/repositories/home_repository.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'home_get_stats_usecase.g.dart'; + +@riverpod +HomeGetStatsUsecase homeGetStatsUsecase(HomeGetStatsUsecaseRef ref) { + return HomeGetStatsUsecase( + homeRepository: ref.read(homeRepositoryProvider) + ); +} + +class HomeGetStatsUsecase extends UsecaseWithParams { + + final HomeRepository _homeRepository; + + const HomeGetStatsUsecase({ + required HomeRepository homeRepository + }) : _homeRepository = homeRepository; + + @override + ResultFuture call(HomeGetStatsUsecaseParams params) async { + return await _homeRepository.getStats(year: params.year); + } +} + +class HomeGetStatsUsecaseParams extends Equatable { + + final int year; + + const HomeGetStatsUsecaseParams({ + required this.year + }); + + @override + List get props => [ + year + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/home/presentation/app/home_page_notifier.dart b/flutter/lib/src/features/home/presentation/app/home_page_notifier.dart new file mode 100644 index 0000000..84879fb --- /dev/null +++ b/flutter/lib/src/features/home/presentation/app/home_page_notifier.dart @@ -0,0 +1,89 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/core/common/models/error_details.dart'; +import 'package:habib_app/src/features/home/domain/entities/stats_entity.dart'; +import 'package:habib_app/src/features/home/domain/usecases/home_get_stats_usecase.dart'; +import 'package:habib_app/core/utils/result.dart'; + +part 'home_page_notifier.g.dart'; + +@riverpod +class HomePageNotifier extends _$HomePageNotifier { + + late HomeGetStatsUsecase _homeGetStatsUsecase; + + @override + HomePageState build() { + _homeGetStatsUsecase = ref.read(homeGetStatsUsecaseProvider); + return const HomePageState(); + } + + Future fetch(int year) async { + if (state.isLoading) return; + + state = state.copyWith( + isStatsLoading: true, + removeError: true + ); + + final HomeGetStatsUsecaseParams homeGetStatsUsecaseParams = HomeGetStatsUsecaseParams(year: year); + final Result result = await _homeGetStatsUsecase.call(homeGetStatsUsecaseParams); + + result.fold( + onSuccess: (StatsEntity stats) { + state = state.copyWith( + isStatsLoading: false, + stats: stats + ); + }, + onFailure: (Object error, StackTrace stackTrace) { + state = state.copyWith( + isStatsLoading: false, + error: ErrorDetails( + error: error, + stackTrace: stackTrace + ) + ); + } + ); + } +} + +class HomePageState extends Equatable { + + final bool isStatsLoading; + final ErrorDetails? error; + final StatsEntity? stats; + + const HomePageState({ + this.isStatsLoading = false, + this.error, + this.stats + }); + + bool get hasError => error != null; + bool get isLoading => isStatsLoading; + bool get hasStats => stats != null; + + HomePageState copyWith({ + bool? isStatsLoading = false, + ErrorDetails? error, + StatsEntity? stats, + bool removeError = false, + bool removeStats = false + }) { + return HomePageState( + isStatsLoading: isStatsLoading ?? this.isStatsLoading, + error: removeError ? null : error ?? this.error, + stats: removeStats ? null : stats ?? this.stats + ); + } + + @override + List get props => [ + isStatsLoading, + error, + stats + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/home/presentation/pages/home_page.dart b/flutter/lib/src/features/home/presentation/pages/home_page.dart index e911adf..ea4a573 100644 --- a/flutter/lib/src/features/home/presentation/pages/home_page.dart +++ b/flutter/lib/src/features/home/presentation/pages/home_page.dart @@ -1,18 +1,185 @@ import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:syncfusion_flutter_charts/charts.dart'; + +import 'package:habib_app/src/features/home/domain/entities/new_books_bought_entity.dart'; +import 'package:habib_app/src/features/home/domain/entities/number_borrowed_books_entity.dart'; +import 'package:habib_app/core/extensions/object_extension.dart'; +import 'package:habib_app/core/utils/enums/toast_type.dart'; import 'package:habib_app/core/common/widgets/hb_app_bar.dart'; +import 'package:habib_app/core/common/widgets/hb_button.dart'; +import 'package:habib_app/core/common/widgets/hb_gap.dart'; import 'package:habib_app/core/common/widgets/hb_scaffold.dart'; +import 'package:habib_app/core/extensions/context_extension.dart'; +import 'package:habib_app/core/res/hb_icons.dart'; +import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; +import 'package:habib_app/core/utils/core_utils.dart'; +import 'package:habib_app/src/features/home/presentation/app/home_page_notifier.dart'; +import 'package:habib_app/core/res/theme/colors/hb_colors.dart'; +import 'package:habib_app/core/res/theme/typography/hb_typography.dart'; + +class HomePage extends ConsumerStatefulWidget { + + const HomePage({super.key}); + + @override + ConsumerState createState() => _HomePageState(); +} + +class _HomePageState extends ConsumerState { + + Future _onExport() async { + // TODO + } + + Future _onRefresh() async { + ref.read(homePageNotifierProvider.notifier).fetch(2024); + } -class HomePage extends StatelessWidget { + void _onPageStateUpdate(HomePageState? _, HomePageState next) { + if (next.hasError) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: next.error!.errorTitle, + description: next.error!.errorDescription, + ); + } + } - const HomePage({ super.key }); + @override + void initState() { + super.initState(); + + CoreUtils.postFrameCall(() { + ref.read(homePageNotifierProvider.notifier).fetch(2024); + }); + } @override Widget build(BuildContext context) { + + final HomePageState pageState = ref.watch(homePageNotifierProvider); + + ref.listen( + homePageNotifierProvider, + _onPageStateUpdate + ); + return HBScaffold( appBar: HBAppBar( - context: context, + context: context, title: 'Startseite' + ), + body: Column( + children: [ + Padding( + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + top: HBSpacing.lg, + bottom: HBSpacing.lg + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + HBButton.shrinkFill( + onPressed: _onExport, + icon: HBIcons.arrowDownTray, + title: 'Export' + ), + const HBGap.md(), + HBButton.shrinkFill( + onPressed: _onRefresh, + icon: HBIcons.arrowPath + ) + ] + ) + ), + Expanded( + child: pageState.hasStats + ? SingleChildScrollView( + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + bottom: context.bottomPadding + HBSpacing.lg + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: SfCartesianChart( + title: ChartTitle( + text: 'Ausgeliehene Bücher', + alignment: ChartAlignment.near, + textStyle: HBTypography.base.copyWith( + fontSize: 20.0, + fontWeight: FontWeight.w600, + color: HBColors.gray900 + ) + ), + primaryXAxis: const CategoryAxis(), + series: >[ + ColumnSeries( + dataSource: pageState.stats!.numberBorrowedBooksList, + xValueMapper: (NumberBorrowedBooksEntity numberBorrowedBooks, int _) => CoreUtils.textFromMonth(numberBorrowedBooks.month), + yValueMapper: (NumberBorrowedBooksEntity numberBorrowedBooks, int _) => numberBorrowedBooks.booksCount + ) + ] + ) + ), + const HBGap.lg(), + Expanded( + child: SfCartesianChart( + title: ChartTitle( + text: 'Gekaufte neue Bücher', + alignment: ChartAlignment.near, + textStyle: HBTypography.base.copyWith( + fontSize: 20.0, + fontWeight: FontWeight.w600, + color: HBColors.gray900 + ) + ), + primaryXAxis: const CategoryAxis(), + margin: EdgeInsets.zero, + series: >[ + StackedColumnSeries( + dataSource: pageState.stats!.newBooksBoughtList, + xValueMapper: (NewBooksBoughtEntity newBooksBought, int _) => CoreUtils.textFromMonth(newBooksBought.month), + yValueMapper: (NewBooksBoughtEntity newBooksBought, int _) => newBooksBought.notBoughtCount + ), + StackedColumnSeries( + dataSource: pageState.stats!.newBooksBoughtList, + xValueMapper: (NewBooksBoughtEntity newBooksBought, int _) => CoreUtils.textFromMonth(newBooksBought.month), + yValueMapper: (NewBooksBoughtEntity newBooksBought, int _) => newBooksBought.boughtCount + ) + ] + ) + ) + ], + ) + ] + ) + ) + : pageState.hasError + ? Center( + child: Text( + pageState.error!.errorDescription, + textAlign: TextAlign.center, + style: HBTypography.base.copyWith( + fontSize: 14.0, + fontWeight: FontWeight.w600, + color: HBColors.gray900 + ), + ) + ) + : const SizedBox.shrink() + ) + ] ) ); } diff --git a/flutter/lib/src/features/publishers/data/datasources/publisher_datasource.dart b/flutter/lib/src/features/publishers/data/datasources/publisher_datasource.dart new file mode 100644 index 0000000..860f83e --- /dev/null +++ b/flutter/lib/src/features/publishers/data/datasources/publisher_datasource.dart @@ -0,0 +1,36 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/publishers/data/dto/publisher_details_dto.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/publishers/data/dto/publisher_dto.dart'; +import 'package:habib_app/src/features/publishers/data/datasources/publisher_datasource_impl.dart'; +import 'package:habib_app/core/services/database.dart'; + +part 'publisher_datasource.g.dart'; + +@riverpod +PublisherDatasource publisherDatasource(PublisherDatasourceRef ref) { + return PublisherDatasourceImpl( + database: ref.read(databaseProvider) + ); +} + +abstract interface class PublisherDatasource { + + const PublisherDatasource(); + + Future> getPublishers({ required String searchText, required int currentPage }); + + Future createPublisher({ + required Json publisherJson + }); + + Future getPublisher({ required int publisherId }); + + Future updatePublisher({ + required int publisherId, + required Json publisherJson + }); + + Future deletePublisher({ required int publisherId }); +} \ No newline at end of file diff --git a/flutter/lib/src/features/publishers/data/datasources/publisher_datasource_impl.dart b/flutter/lib/src/features/publishers/data/datasources/publisher_datasource_impl.dart new file mode 100644 index 0000000..cb03d96 --- /dev/null +++ b/flutter/lib/src/features/publishers/data/datasources/publisher_datasource_impl.dart @@ -0,0 +1,52 @@ +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/core/services/database.dart'; +import 'package:habib_app/src/features/publishers/data/datasources/publisher_datasource.dart'; +import 'package:habib_app/src/features/publishers/data/dto/publisher_details_dto.dart'; +import 'package:habib_app/src/features/publishers/data/dto/publisher_dto.dart'; + +class PublisherDatasourceImpl implements PublisherDatasource { + + final Database _database; + + const PublisherDatasourceImpl({ + required Database database + }) : _database = database; + + @override + Future> getPublishers({ required String searchText, required int currentPage }) async { + final List jsonList = await _database.getPublishers( + searchText: searchText, + currentPage: currentPage + ); + return PublisherDto.listFromJsonList(jsonList); + } + + @override + Future createPublisher({ + required Json publisherJson + }) async { + return await _database.createPublisher(publisherJson: publisherJson); + } + + @override + Future getPublisher({ required int publisherId }) async { + final Json json = await _database.getPublisher(publisherId: publisherId); + return PublisherDetailsDto.fromJson(json); + } + + @override + Future updatePublisher({ + required int publisherId, + required Json publisherJson + }) async { + return await _database.updatePublisher( + publisherId, + publisherJson + ); + } + + @override + Future deletePublisher({ required int publisherId }) async { + return await _database.deletePublisher(publisherId); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/publishers/data/dto/publisher_details_dto.dart b/flutter/lib/src/features/publishers/data/dto/publisher_details_dto.dart new file mode 100644 index 0000000..f6b38ed --- /dev/null +++ b/flutter/lib/src/features/publishers/data/dto/publisher_details_dto.dart @@ -0,0 +1,19 @@ +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/publishers/domain/entities/publisher_details_entity.dart'; + +class PublisherDetailsDto extends PublisherDetailsEntity { + + const PublisherDetailsDto({ + required super.id, + required super.name, + super.city + }); + + factory PublisherDetailsDto.fromJson(Json publisherJson) { + return PublisherDetailsDto( + id: publisherJson['publisher_id'] as int, + name: publisherJson['publisher_name'] as String, + city: publisherJson['publisher_city'] as String? + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/publishers/data/dto/publisher_dto.dart b/flutter/lib/src/features/publishers/data/dto/publisher_dto.dart new file mode 100644 index 0000000..518cba2 --- /dev/null +++ b/flutter/lib/src/features/publishers/data/dto/publisher_dto.dart @@ -0,0 +1,23 @@ +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/publishers/domain/entities/publisher_entity.dart'; + +class PublisherDto extends PublisherEntity { + + const PublisherDto({ + required super.id, + required super.name, + super.city + }); + + factory PublisherDto.fromJson(Json publisherJson) { + return PublisherDto( + id: publisherJson['publisher_id'] as int, + name: publisherJson['publisher_name'] as String, + city: publisherJson['publisher_title'] as String? + ); + } + + static List listFromJsonList(List jsonList) { + return jsonList.map((Json json) => PublisherDto.fromJson(json)).toList(); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/publishers/data/repositories/publisher_repository_impl.dart b/flutter/lib/src/features/publishers/data/repositories/publisher_repository_impl.dart new file mode 100644 index 0000000..9fe4f82 --- /dev/null +++ b/flutter/lib/src/features/publishers/data/repositories/publisher_repository_impl.dart @@ -0,0 +1,78 @@ +import 'package:habib_app/core/utils/result.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/src/features/publishers/data/datasources/publisher_datasource.dart'; +import 'package:habib_app/src/features/publishers/data/dto/publisher_details_dto.dart'; +import 'package:habib_app/src/features/publishers/data/dto/publisher_dto.dart'; +import 'package:habib_app/src/features/publishers/domain/entities/publisher_details_entity.dart'; +import 'package:habib_app/src/features/publishers/domain/entities/publisher_entity.dart'; +import 'package:habib_app/src/features/publishers/domain/repositories/publisher_repository.dart'; + +class PublisherRepositoryImpl implements PublisherRepository { + + final PublisherDatasource _publisherDatasource; + + const PublisherRepositoryImpl({ + required PublisherDatasource publisherDatasource + }) : _publisherDatasource = publisherDatasource; + + @override + ResultFuture> getPublishers({ required String searchText, required int currentPage }) async { + try { + final List result = await _publisherDatasource.getPublishers( + searchText: searchText, + currentPage: currentPage + ); + return Success(result); + } catch (e) { + return Failure(e); + } + } + + @override + ResultFuture createPublisher({ + required Json publisherJson + }) async { + try { + final int publisherId = await _publisherDatasource.createPublisher(publisherJson: publisherJson); + return Success(publisherId); + } catch (e) { + return Failure(e); + } + } + + @override + ResultFuture getPublisher({ required int publisherId }) async { + try { + final PublisherDetailsDto result = await _publisherDatasource.getPublisher(publisherId: publisherId); + return Success(result); + } catch (e) { + return Failure(e); + } + } + + @override + ResultFuture updatePublisher({ + required int publisherId, + required Json publisherJson + }) async { + try { + await _publisherDatasource.updatePublisher( + publisherId: publisherId, + publisherJson: publisherJson + ); + return const Success(null); + } catch (e) { + return Failure(e); + } + } + + @override + ResultFuture deletePublisher({ required int publisherId }) async { + try { + await _publisherDatasource.deletePublisher(publisherId: publisherId); + return const Success(null); + } catch (e) { + return Failure(e); + } + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/publishers/domain/entities/publisher_details_entity.dart b/flutter/lib/src/features/publishers/domain/entities/publisher_details_entity.dart new file mode 100644 index 0000000..aabf2e9 --- /dev/null +++ b/flutter/lib/src/features/publishers/domain/entities/publisher_details_entity.dart @@ -0,0 +1,21 @@ +import 'package:equatable/equatable.dart'; + +class PublisherDetailsEntity extends Equatable { + + final int id; + final String name; + final String? city; + + const PublisherDetailsEntity({ + required this.id, + required this.name, + this.city + }); + + @override + List get props => [ + id, + name, + city + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/publishers/domain/entities/publisher_entity.dart b/flutter/lib/src/features/publishers/domain/entities/publisher_entity.dart new file mode 100644 index 0000000..6f9f295 --- /dev/null +++ b/flutter/lib/src/features/publishers/domain/entities/publisher_entity.dart @@ -0,0 +1,21 @@ +import 'package:equatable/equatable.dart'; + +class PublisherEntity extends Equatable { + + final int id; + final String name; + final String? city; + + const PublisherEntity({ + required this.id, + required this.name, + this.city + }); + + @override + List get props => [ + id, + name, + city + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/publishers/domain/repositories/publisher_repository.dart b/flutter/lib/src/features/publishers/domain/repositories/publisher_repository.dart new file mode 100644 index 0000000..93142e9 --- /dev/null +++ b/flutter/lib/src/features/publishers/domain/repositories/publisher_repository.dart @@ -0,0 +1,34 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/publishers/domain/entities/publisher_details_entity.dart'; +import 'package:habib_app/src/features/publishers/data/datasources/publisher_datasource.dart'; +import 'package:habib_app/src/features/publishers/data/repositories/publisher_repository_impl.dart'; +import 'package:habib_app/src/features/publishers/domain/entities/publisher_entity.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'publisher_repository.g.dart'; + +@riverpod +PublisherRepository publisherRepository(PublisherRepositoryRef ref) { + return PublisherRepositoryImpl( + publisherDatasource: ref.read(publisherDatasourceProvider) + ); +} + +abstract interface class PublisherRepository { + + ResultFuture> getPublishers({ required String searchText, required int currentPage }); + + ResultFuture createPublisher({ + required Json publisherJson + }); + + ResultFuture getPublisher({ required int publisherId }); + + ResultFuture updatePublisher({ + required int publisherId, + required Json publisherJson + }); + + ResultFuture deletePublisher({ required int publisherId }); +} \ No newline at end of file diff --git a/flutter/lib/src/features/publishers/domain/usecases/publisher_create_publisher_usecase.dart b/flutter/lib/src/features/publishers/domain/usecases/publisher_create_publisher_usecase.dart new file mode 100644 index 0000000..632341d --- /dev/null +++ b/flutter/lib/src/features/publishers/domain/usecases/publisher_create_publisher_usecase.dart @@ -0,0 +1,43 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/publishers/domain/repositories/publisher_repository.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'publisher_create_publisher_usecase.g.dart'; + +@riverpod +PublisherCreatePublisherUsecase publisherCreatePublisherUsecase(PublisherCreatePublisherUsecaseRef ref) { + return PublisherCreatePublisherUsecase( + publisherRepository: ref.read(publisherRepositoryProvider) + ); +} + +class PublisherCreatePublisherUsecase extends UsecaseWithParams { + + final PublisherRepository _publisherRepository; + + const PublisherCreatePublisherUsecase({ + required PublisherRepository publisherRepository + }) : _publisherRepository = publisherRepository; + + @override + ResultFuture call(PublisherCreatePublisherUsecaseParams params) async { + return await _publisherRepository.createPublisher(publisherJson: params.publisherJson); + } +} + +class PublisherCreatePublisherUsecaseParams extends Equatable { + + final Json publisherJson; + + const PublisherCreatePublisherUsecaseParams({ + required this.publisherJson + }); + + @override + List get props => [ + publisherJson + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/publishers/domain/usecases/publisher_get_publishers_usecase.dart b/flutter/lib/src/features/publishers/domain/usecases/publisher_get_publishers_usecase.dart new file mode 100644 index 0000000..87c27df --- /dev/null +++ b/flutter/lib/src/features/publishers/domain/usecases/publisher_get_publishers_usecase.dart @@ -0,0 +1,50 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/publishers/domain/entities/publisher_entity.dart'; +import 'package:habib_app/src/features/publishers/domain/repositories/publisher_repository.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'publisher_get_publishers_usecase.g.dart'; + +@riverpod +PublisherGetPublishersUsecase publisherGetPublishersUsecase(PublisherGetPublishersUsecaseRef ref) { + return PublisherGetPublishersUsecase( + publisherRepository: ref.read(publisherRepositoryProvider) + ); +} + +class PublisherGetPublishersUsecase extends UsecaseWithParams, PublisherGetPublishersUsecaseParams> { + + final PublisherRepository _publisherRepository; + + const PublisherGetPublishersUsecase({ + required PublisherRepository publisherRepository + }) : _publisherRepository = publisherRepository; + + @override + ResultFuture> call(PublisherGetPublishersUsecaseParams params) async { + return await _publisherRepository.getPublishers( + searchText: params.searchText, + currentPage: params.currentPage + ); + } +} + +class PublisherGetPublishersUsecaseParams extends Equatable { + + final String searchText; + final int currentPage; + + const PublisherGetPublishersUsecaseParams({ + required this.searchText, + required this.currentPage + }); + + @override + List get props => [ + searchText, + currentPage + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/publishers/presentation/app/publisher_delete_publisher_usecase.dart b/flutter/lib/src/features/publishers/presentation/app/publisher_delete_publisher_usecase.dart new file mode 100644 index 0000000..1c21bcf --- /dev/null +++ b/flutter/lib/src/features/publishers/presentation/app/publisher_delete_publisher_usecase.dart @@ -0,0 +1,41 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/publishers/domain/repositories/publisher_repository.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'publisher_delete_publisher_usecase.g.dart'; + +@riverpod +PublisherDeletePublisherUsecase publisherDeletePublisherUsecase(PublisherDeletePublisherUsecaseRef ref) { + return PublisherDeletePublisherUsecase( + publisherRepository: ref.read(publisherRepositoryProvider) + ); +} + +class PublisherDeletePublisherUsecase extends UsecaseWithParams { + + final PublisherRepository _publisherRepository; + + const PublisherDeletePublisherUsecase({ + required PublisherRepository publisherRepository + }) : _publisherRepository = publisherRepository; + + @override + ResultFuture call(PublisherDeletePublisherUsecaseParams params) async { + return await _publisherRepository.deletePublisher(publisherId: params.publisherId); + } +} + +class PublisherDeletePublisherUsecaseParams extends Equatable { + + final int publisherId; + + const PublisherDeletePublisherUsecaseParams({ required this.publisherId }); + + @override + List get props => [ + publisherId + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/publishers/presentation/app/publisher_details_page_notifier.dart b/flutter/lib/src/features/publishers/presentation/app/publisher_details_page_notifier.dart new file mode 100644 index 0000000..9328523 --- /dev/null +++ b/flutter/lib/src/features/publishers/presentation/app/publisher_details_page_notifier.dart @@ -0,0 +1,93 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/publishers/domain/entities/publisher_details_entity.dart'; +import 'package:habib_app/src/features/publishers/presentation/app/publisher_get_publisher_usecase.dart'; +import 'package:habib_app/core/common/models/error_details.dart'; +import 'package:habib_app/core/utils/result.dart'; + +part 'publisher_details_page_notifier.g.dart'; + +@riverpod +class PublisherDetailsPageNotifier extends _$PublisherDetailsPageNotifier { + + late PublisherGetPublisherUsecase _publisherGetPublisherUsecase; + + @override + PublisherDetailsPageState build(int publisherId) { + _publisherGetPublisherUsecase = ref.read(publisherGetPublisherUsecaseProvider); + return const PublisherDetailsPageState(); + } + + void replace(PublisherDetailsEntity publisher) { + state = state.copyWith(publisher: publisher); + } + + Future fetch() async { + if (state.isLoading) return; + + state = state.copyWith( + isPublisherLoading: true, + removeError: true + ); + + final PublisherGetPublisherUsecaseParams publisherParams = PublisherGetPublisherUsecaseParams(publisherId: publisherId); + final Result result = await _publisherGetPublisherUsecase.call(publisherParams); + + result.fold( + onSuccess: (PublisherDetailsEntity publisher) { + state = state.copyWith( + isPublisherLoading: false, + publisher: publisher + ); + }, + onFailure: (Object error, StackTrace stackTrace) { + state = state.copyWith( + isPublisherLoading: false, + error: ErrorDetails( + error: error, + stackTrace: stackTrace + ) + ); + } + ); + } +} + +class PublisherDetailsPageState extends Equatable { + + final bool isPublisherLoading; + final ErrorDetails? error; + final PublisherDetailsEntity? publisher; + + const PublisherDetailsPageState({ + this.isPublisherLoading = false, + this.error, + this.publisher + }); + + bool get hasError => error != null; + bool get isLoading => isPublisherLoading; + bool get hasPublisher => publisher != null; + + PublisherDetailsPageState copyWith({ + bool? isPublisherLoading = false, + ErrorDetails? error, + PublisherDetailsEntity? publisher, + bool removeError = false, + bool removePublisher = false + }) { + return PublisherDetailsPageState( + isPublisherLoading: isPublisherLoading ?? this.isPublisherLoading, + error: removeError ? null : error ?? this.error, + publisher: removePublisher ? null : publisher ?? this.publisher + ); + } + + @override + List get props => [ + isPublisherLoading, + error, + publisher + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/publishers/presentation/app/publisher_get_publisher_usecase.dart b/flutter/lib/src/features/publishers/presentation/app/publisher_get_publisher_usecase.dart new file mode 100644 index 0000000..964c60b --- /dev/null +++ b/flutter/lib/src/features/publishers/presentation/app/publisher_get_publisher_usecase.dart @@ -0,0 +1,42 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/publishers/domain/entities/publisher_details_entity.dart'; +import 'package:habib_app/src/features/publishers/domain/repositories/publisher_repository.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'publisher_get_publisher_usecase.g.dart'; + +@riverpod +PublisherGetPublisherUsecase publisherGetPublisherUsecase(PublisherGetPublisherUsecaseRef ref) { + return PublisherGetPublisherUsecase( + publisherRepository: ref.read(publisherRepositoryProvider) + ); +} + +class PublisherGetPublisherUsecase extends UsecaseWithParams { + + final PublisherRepository _publisherRepository; + + const PublisherGetPublisherUsecase({ + required PublisherRepository publisherRepository + }) : _publisherRepository = publisherRepository; + + @override + ResultFuture call(PublisherGetPublisherUsecaseParams params) async { + return await _publisherRepository.getPublisher(publisherId: params.publisherId); + } +} + +class PublisherGetPublisherUsecaseParams extends Equatable { + + final int publisherId; + + const PublisherGetPublisherUsecaseParams({ required this.publisherId }); + + @override + List get props => [ + publisherId + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/publishers/presentation/app/publisher_update_publisher_usecase.dart b/flutter/lib/src/features/publishers/presentation/app/publisher_update_publisher_usecase.dart new file mode 100644 index 0000000..4796f39 --- /dev/null +++ b/flutter/lib/src/features/publishers/presentation/app/publisher_update_publisher_usecase.dart @@ -0,0 +1,49 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/src/features/publishers/domain/repositories/publisher_repository.dart'; +import 'package:habib_app/core/usecase/usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; + +part 'publisher_update_publisher_usecase.g.dart'; + +@riverpod +PublisherUpdatePublisherUsecase publisherUpdatePublisherUsecase(PublisherUpdatePublisherUsecaseRef ref) { + return PublisherUpdatePublisherUsecase( + publisherRepository: ref.read(publisherRepositoryProvider) + ); +} + +class PublisherUpdatePublisherUsecase extends UsecaseWithParams { + + final PublisherRepository _publisherRepository; + + const PublisherUpdatePublisherUsecase({ + required PublisherRepository publisherRepository + }) : _publisherRepository = publisherRepository; + + @override + ResultFuture call(PublisherUpdatePublisherUsecaseParams params) async { + return await _publisherRepository.updatePublisher( + publisherId: params.publisherId, + publisherJson: params.publisherJson + ); + } +} + +class PublisherUpdatePublisherUsecaseParams extends Equatable { + + final int publisherId; + final Json publisherJson; + + const PublisherUpdatePublisherUsecaseParams({ + required this.publisherId, + required this.publisherJson + }); + + @override + List get props => [ + publisherId, + publisherJson + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/publishers/presentation/app/publishers_page_notifier.dart b/flutter/lib/src/features/publishers/presentation/app/publishers_page_notifier.dart new file mode 100644 index 0000000..29daaed --- /dev/null +++ b/flutter/lib/src/features/publishers/presentation/app/publishers_page_notifier.dart @@ -0,0 +1,112 @@ +import 'package:equatable/equatable.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/core/common/models/error_details.dart'; +import 'package:habib_app/src/features/publishers/domain/entities/publisher_entity.dart'; +import 'package:habib_app/src/features/publishers/domain/usecases/publisher_get_publishers_usecase.dart'; +import 'package:habib_app/core/utils/constants/network_constants.dart'; +import 'package:habib_app/core/utils/result.dart'; + +part 'publishers_page_notifier.g.dart'; + +@riverpod +class PublishersPageNotifier extends _$PublishersPageNotifier { + + late PublisherGetPublishersUsecase _publisherGetPublishersUsecase; + + @override + PublishersPageState build() { + _publisherGetPublishersUsecase = ref.read(publisherGetPublishersUsecaseProvider); + return const PublishersPageState(); + } + + Future fetchNextPage(String searchText) async { + if (state.isLoading || state.hasReachedEnd) return; + + state = state.copyWith( + isPublishersLoading: true, + removeError: true + ); + + final PublisherGetPublishersUsecaseParams params = PublisherGetPublishersUsecaseParams( + searchText: searchText, + currentPage: state.currentPage + ); + final Result> result = await _publisherGetPublishersUsecase.call(params); + + result.fold( + onSuccess: (List publishers) { + state = state.copyWith( + isPublishersLoading: false, + currentPage: publishers.isEmpty + ? state.currentPage + : state.currentPage + 1, + publishers: List.of(state.publishers)..addAll(publishers), + hasReachedEnd: publishers.length < NetworkConstants.pageSize + ); + }, + onFailure: (Object error, StackTrace stackTrace) { + state = state.copyWith( + isPublishersLoading: false, + error: ErrorDetails( + error: error, + stackTrace: stackTrace + ) + ); + } + ); + } + + Future refresh(String searchText) async { + state = const PublishersPageState(); + await fetchNextPage(searchText); + } +} + +class PublishersPageState extends Equatable { + + final bool isPublishersLoading; + final ErrorDetails? error; + final List publishers; + final bool hasReachedEnd; + final int currentPage; + + const PublishersPageState({ + this.isPublishersLoading = false, + this.error, + this.publishers = const [], + this.hasReachedEnd = false, + this.currentPage = 1 + }); + + bool get hasError => error != null; + bool get isLoading => isPublishersLoading; + bool get hasPublishers => publishers.isNotEmpty; + + PublishersPageState copyWith({ + bool? isPublishersLoading = false, + ErrorDetails? error, + List? publishers, + bool? hasReachedEnd, + int? currentPage, + bool removeError = false, + bool removePublishers = false + }) { + return PublishersPageState( + isPublishersLoading: isPublishersLoading ?? this.isPublishersLoading, + error: removeError ? null : error ?? this.error, + publishers: removePublishers ? const [] : publishers ?? this.publishers, + hasReachedEnd: hasReachedEnd ?? this.hasReachedEnd, + currentPage: currentPage ?? this.currentPage + ); + } + + @override + List get props => [ + isPublishersLoading, + error, + publishers, + hasReachedEnd, + currentPage + ]; +} \ No newline at end of file diff --git a/flutter/lib/src/features/publishers/presentation/pages/create_publisher_page.dart b/flutter/lib/src/features/publishers/presentation/pages/create_publisher_page.dart new file mode 100644 index 0000000..e2fa226 --- /dev/null +++ b/flutter/lib/src/features/publishers/presentation/pages/create_publisher_page.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:habib_app/src/features/publishers/domain/usecases/publisher_create_publisher_usecase.dart'; +import 'package:habib_app/core/common/models/error_details.dart'; +import 'package:habib_app/core/common/widgets/hb_dialog.dart'; +import 'package:habib_app/core/common/widgets/hb_gap.dart'; +import 'package:habib_app/core/common/widgets/hb_text_field.dart'; +import 'package:habib_app/core/extensions/object_extension.dart'; +import 'package:habib_app/core/res/hb_icons.dart'; +import 'package:habib_app/core/services/routes.dart'; +import 'package:habib_app/core/utils/core_utils.dart'; +import 'package:habib_app/core/utils/enums/toast_type.dart'; +import 'package:habib_app/core/utils/result.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/core/utils/validator.dart'; + +class CreatePublisherPage extends StatefulHookConsumerWidget { + + const CreatePublisherPage({ super.key }); + + @override + ConsumerState createState() => _CreatePublisherPageState(); +} + +class _CreatePublisherPageState extends ConsumerState { + + late TextEditingController _nameController; + late TextEditingController _cityController; + + Future _handleCreate() async { + final String name = _nameController.text.trim(); + final String city = _cityController.text.trim(); + + try { + Validator.validatePublisherCreate( + name: name, + city: city + ); + } catch (e) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: e.errorTitle, + description: e.errorDescription, + ); + return; + } + + Json publisherJson = { + 'name' : "'$name'", + if (city.isNotEmpty) 'city' : "'$city'" + }; + + final PublisherCreatePublisherUsecase publisherCreatePublisherUsecase = ref.read(publisherCreatePublisherUsecaseProvider); + final PublisherCreatePublisherUsecaseParams publisherCreatePublisherUsecaseParams = PublisherCreatePublisherUsecaseParams(publisherJson: publisherJson); + final Result publisherCreatePublisherUsecaseResult = await publisherCreatePublisherUsecase.call(publisherCreatePublisherUsecaseParams); + + publisherCreatePublisherUsecaseResult.fold( + onSuccess: (int publisherId) async { + CoreUtils.showToast( + context, + type: ToastType.success, + title: 'Erfolgreich angelegt.', + description: 'Der Verlag wurde erfolgreich angelegt.' + ); + + if (!mounted) return; + + context.pop(); + + await PublisherDetailsRoute(publisherId: publisherId).push(context); + }, + onFailure: (Object error, StackTrace stackTrace) { + final ErrorDetails errorDetails = ErrorDetails( + error: error, + stackTrace: stackTrace + ); + CoreUtils.showToast( + context, + type: ToastType.error, + title: errorDetails.errorTitle, + description: errorDetails.errorDescription, + ); + } + ); + } + + @override + Widget build(BuildContext context) { + + _nameController = useTextEditingController(); + _cityController = useTextEditingController(); + + return HBDialog( + title: 'Neuer Verlag', + actionButton: HBDialogActionButton( + onPressed: _handleCreate, + title: 'Erstellen' + ), + children: [ + const HBDialogSection( + title: 'Allgemeine Details', + isFirstSection: true + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Expanded( + child: HBTextField( + controller: _nameController, + icon: HBIcons.academicCap, + title: 'Name' + ) + ), + const HBGap.lg(), + Expanded( + child: HBTextField( + controller: _cityController, + icon: HBIcons.user, + title: 'Stadt' + ) + ) + ] + ) + ] + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/publishers/presentation/pages/publisher_details_page.dart b/flutter/lib/src/features/publishers/presentation/pages/publisher_details_page.dart new file mode 100644 index 0000000..ea765c6 --- /dev/null +++ b/flutter/lib/src/features/publishers/presentation/pages/publisher_details_page.dart @@ -0,0 +1,282 @@ +import 'package:flutter/material.dart'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:habib_app/src/features/publishers/domain/entities/publisher_details_entity.dart'; +import 'package:habib_app/src/features/publishers/presentation/app/publisher_delete_publisher_usecase.dart'; +import 'package:habib_app/src/features/publishers/presentation/app/publisher_details_page_notifier.dart'; +import 'package:habib_app/src/features/publishers/presentation/app/publisher_update_publisher_usecase.dart'; +import 'package:habib_app/core/utils/typedefs.dart'; +import 'package:habib_app/core/utils/validator.dart'; +import 'package:habib_app/core/res/theme/colors/hb_colors.dart'; +import 'package:habib_app/core/res/theme/typography/hb_typography.dart'; +import 'package:habib_app/core/common/widgets/hb_gap.dart'; +import 'package:habib_app/core/common/widgets/hb_text_field.dart'; +import 'package:habib_app/core/extensions/context_extension.dart'; +import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; +import 'package:habib_app/core/common/models/error_details.dart'; +import 'package:habib_app/core/common/widgets/hb_message_box.dart'; +import 'package:habib_app/core/utils/result.dart'; +import 'package:habib_app/core/extensions/object_extension.dart'; +import 'package:habib_app/core/utils/enums/toast_type.dart'; +import 'package:habib_app/core/common/widgets/hb_app_bar.dart'; +import 'package:habib_app/core/common/widgets/hb_scaffold.dart'; +import 'package:habib_app/core/res/hb_icons.dart'; +import 'package:habib_app/core/utils/core_utils.dart'; + +class PublisherDetailsPageParams { + + final int publisherId; + + const PublisherDetailsPageParams({ + required this.publisherId + }); +} + +class PublisherDetailsPage extends StatefulHookConsumerWidget { + + final PublisherDetailsPageParams params; + + const PublisherDetailsPage({ + super.key, + required this.params + }); + + @override + ConsumerState createState() => _PublisherDetailsPageState(); +} + +class _PublisherDetailsPageState extends ConsumerState { + + late TextEditingController _nameController; + late TextEditingController _cityController; + + void _onDetailsStateUpdate(PublisherDetailsPageState? _, PublisherDetailsPageState next) { + if (next.hasError) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: next.error!.errorTitle, + description: next.error!.errorDescription, + ); + } + } + + Future _onRefresh() async { + ref.read(publisherDetailsPageNotifierProvider(widget.params.publisherId).notifier).fetch(); + } + + Future _onSave() async { + final PublisherDetailsPageState pageState = ref.watch(publisherDetailsPageNotifierProvider(widget.params.publisherId)); + final PublisherDetailsEntity publisher = pageState.publisher!; + + final String name = _nameController.text.trim(); + final String city = _cityController.text.trim(); + + try { + Validator.validatePublisherUpdate( + name: name, + city: city + ); + } catch (e) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: e.errorTitle, + description: e.errorDescription, + ); + return; + } + + final bool replaceName = name != publisher.name; + final bool replaceCity = city != (publisher.city ?? ''); + + Json publisherJson = { + if (replaceName) 'name' : "'$name'", + if (replaceCity) 'city' : "'$city'", + }; + + if (publisherJson.isEmpty) return; + + final PublisherUpdatePublisherUsecase publisherUpdatePublisherUsecase = ref.read(publisherUpdatePublisherUsecaseProvider); + final PublisherUpdatePublisherUsecaseParams publisherUpdatePublisherUsecaseParams = PublisherUpdatePublisherUsecaseParams( + publisherId: publisher.id, + publisherJson: publisherJson + ); + final Result publisherUpdatePublisherUsecaseResult = await publisherUpdatePublisherUsecase.call(publisherUpdatePublisherUsecaseParams); + + publisherUpdatePublisherUsecaseResult.fold( + onSuccess: (void _) { + ref.read(publisherDetailsPageNotifierProvider(widget.params.publisherId).notifier).replace( + PublisherDetailsEntity( + id: publisher.id, + name: replaceName ? name : publisher.name, + city: replaceCity ? city : publisher.city + ) + ); + + CoreUtils.showToast( + context, + type: ToastType.success, + title: 'Erfolgreich aktualisiert.', + description: 'Der Verlag wurde erfolgreich aktualisiert.' + ); + }, + onFailure: (Object error, StackTrace stackTrace) { + final ErrorDetails errorDetails = ErrorDetails( + error: error, + stackTrace: stackTrace + ); + CoreUtils.showToast( + context, + type: ToastType.error, + title: errorDetails.errorTitle, + description: errorDetails.errorDescription, + ); + } + ); + } + + Future _onDelete() async { + final bool? success = await showHBMessageBox( + context, + 'Diesen Verlag wirklich löschen?', + 'Wenn Sie diesen Verlag löschen, werden alle damit verbundenen Daten ebenfalls gelöscht. Dieser Vorgang kann nicht rückgängig gemacht werden.', + 'Löschen', + onPressed: () async { + final PublisherDeletePublisherUsecase publisherDeletePublisherUsecase = ref.read(publisherDeletePublisherUsecaseProvider); + final PublisherDeletePublisherUsecaseParams publisherDeletePublisherUsecaseParams = PublisherDeletePublisherUsecaseParams(publisherId: widget.params.publisherId); + final Result publisherDeletePublisherUsecaseResult = await publisherDeletePublisherUsecase.call(publisherDeletePublisherUsecaseParams); + + return publisherDeletePublisherUsecaseResult.fold( + onSuccess: (void _) => true, + onFailure: (Object error, StackTrace stackTrace) { + final ErrorDetails errorDetails = ErrorDetails( + error: error, + stackTrace: stackTrace + ); + CoreUtils.showToast( + context, + type: ToastType.error, + title: errorDetails.errorTitle, + description: errorDetails.errorDescription, + ); + return false; + } + ); + } + ); + + if (mounted && context.canPop() && (success ?? false)) context.pop(true); + } + + @override + void initState() { + super.initState(); + + CoreUtils.postFrameCall(() { + ref.read(publisherDetailsPageNotifierProvider(widget.params.publisherId).notifier).fetch(); + }); + } + + @override + Widget build(BuildContext context) { + + final PublisherDetailsPageState pageState = ref.watch(publisherDetailsPageNotifierProvider(widget.params.publisherId)); + + if (pageState.hasPublisher) { + _nameController = useTextEditingController(text: pageState.publisher!.name); + _cityController = useTextEditingController(text: pageState.publisher!.city); + } + + ref.listen( + publisherDetailsPageNotifierProvider(widget.params.publisherId), + _onDetailsStateUpdate + ); + + return HBScaffold( + appBar: HBAppBar( + context: context, + title: 'Details', + backButton: const HBAppBarBackButton(), + actionButtons: [ + HBAppBarButton( + onPressed: _onRefresh, + icon: HBIcons.arrowPath, + isEnabled: !pageState.isLoading + ), + HBAppBarButton( + onPressed: _onSave, + icon: HBIcons.cloudArrowUp, + isEnabled: pageState.hasPublisher, + ), + HBAppBarButton( + onPressed: _onDelete, + icon: HBIcons.trash, + isEnabled: pageState.hasPublisher + ) + ] + ), + body: pageState.hasPublisher + ? SingleChildScrollView( + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + top: HBSpacing.lg + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Allgemeine Details', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HBTypography.base.copyWith( + fontSize: 20.0, + fontWeight: FontWeight.w600, + color: HBColors.gray900 + ) + ), + const HBGap.xl(), + Row( + children: [ + Expanded( + child: HBTextField( + title: 'Name', + controller: _nameController, + icon: HBIcons.home + ) + ), + const HBGap.xl(), + Expanded( + child: HBTextField( + title: 'Stadt', + controller: _cityController, + icon: HBIcons.home + ) + ), + const HBGap.xl(), + const Spacer() + ] + ) + ] + ) + ) + : pageState.hasError + ? Center( + child: Text( + pageState.error!.errorDescription, + textAlign: TextAlign.center, + style: HBTypography.base.copyWith( + fontSize: 14.0, + fontWeight: FontWeight.w600, + color: HBColors.gray900 + ), + ) + ) + : const SizedBox.shrink() + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/publishers/presentation/pages/publishers_page.dart b/flutter/lib/src/features/publishers/presentation/pages/publishers_page.dart new file mode 100644 index 0000000..5b90461 --- /dev/null +++ b/flutter/lib/src/features/publishers/presentation/pages/publishers_page.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:habib_app/core/extensions/object_extension.dart'; +import 'package:habib_app/src/features/publishers/domain/entities/publisher_entity.dart'; +import 'package:habib_app/src/features/publishers/presentation/app/publishers_page_notifier.dart'; +import 'package:habib_app/core/common/widgets/hb_table.dart'; +import 'package:habib_app/core/services/routes.dart'; +import 'package:habib_app/core/common/widgets/hb_app_bar.dart'; +import 'package:habib_app/core/common/widgets/hb_button.dart'; +import 'package:habib_app/core/common/widgets/hb_gap.dart'; +import 'package:habib_app/core/common/widgets/hb_scaffold.dart'; +import 'package:habib_app/core/common/widgets/hb_text_field.dart'; +import 'package:habib_app/core/extensions/context_extension.dart'; +import 'package:habib_app/core/res/hb_icons.dart'; +import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; +import 'package:habib_app/core/utils/constants/hb_ui_constants.dart'; +import 'package:habib_app/core/utils/core_utils.dart'; +import 'package:habib_app/core/utils/enums/toast_type.dart'; + +class PublishersPage extends StatefulHookConsumerWidget { + + const PublishersPage({ super.key }); + + @override + ConsumerState createState() => _PublishersPageState(); +} + +class _PublishersPageState extends ConsumerState { + + final ScrollController _scrollController = ScrollController(); + + late TextEditingController _searchController; + + void _onPageStateUpdate(PublishersPageState? _, PublishersPageState next) { + if (next.hasError) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: next.error!.errorTitle, + description: next.error!.errorDescription, + ); + } + } + + bool get _isBottom { + if (!_scrollController.hasClients) return false; + final double maxScroll = _scrollController.position.maxScrollExtent; + final double currentScroll = _scrollController.offset; + return currentScroll >= maxScroll * 0.9; + } + + void _onScroll() { + if (_isBottom) { + ref.read(publishersPageNotifierProvider.notifier).fetchNextPage(_searchText); + } + } + + HBTableStatus get _tableStatus { + final PublishersPageState pageState = ref.read(publishersPageNotifierProvider); + if (pageState.hasPublishers) return HBTableStatus.data; + if (pageState.hasError || !pageState.hasPublishers) return HBTableStatus.text; + return HBTableStatus.loading; + } + + String? get _tableText { + final PublishersPageState pageState = ref.read(publishersPageNotifierProvider); + if (!pageState.isLoading && !pageState.hasError && !pageState.hasPublishers) return 'Keine Verlage gefunden.'; + if (pageState.hasError) return 'Ein Fehler ist aufgetreten.'; + return null; + } + + String get _searchText { + return _searchController.text.trim(); + } + + Future _onPublisherPressed(int publisherId) async { + await PublisherDetailsRoute(publisherId: publisherId).push(context); + } + + Future _onCreatePublisher() async { + await const CreatePublisherRoute().push(context); + } + + Future _onSearchChanged(String _) async { + await ref.read(publishersPageNotifierProvider.notifier).refresh(_searchText); + } + + Future _onRefresh() async { + await ref.read(publishersPageNotifierProvider.notifier).refresh(_searchText); + } + + @override + void initState() { + super.initState(); + + _scrollController.addListener(_onScroll); + + CoreUtils.postFrameCall(() { + ref.read(publishersPageNotifierProvider.notifier).fetchNextPage(_searchText); + }); + } + + @override + void dispose() { + _scrollController + ..removeListener(_onScroll) + ..dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + + final PublishersPageState pageState = ref.watch(publishersPageNotifierProvider); + + _searchController = useTextEditingController(); + + ref.listen( + publishersPageNotifierProvider, + _onPageStateUpdate + ); + + return HBScaffold( + appBar: HBAppBar( + context: context, + title: 'Verlage' + ), + body: Column( + children: [ + Padding( + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + top: HBSpacing.lg + ), + child: Row( + children: [ + HBTextField( + controller: _searchController, + onChanged: _onSearchChanged, + icon: HBIcons.magnifyingGlass, + hint: 'Name', + maxWidth: 500.0 + ), + const HBGap.lg(), + const Spacer(), + HBButton.shrinkFill( + onPressed: _onCreatePublisher, + icon: HBIcons.plus, + title: 'Neuer Verlag' + ), + const HBGap.md(), + HBButton.shrinkFill( + onPressed: _onRefresh, + icon: HBIcons.arrowPath + ) + ] + ) + ), + Expanded( + child: HBTable( + onPressed: (int index) => _onPublisherPressed(pageState.publishers[index].id), + status: _tableStatus, + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + bottom: context.bottomPadding + HBSpacing.lg, + top: HBSpacing.xxl + ), + controller: _scrollController, + tableWidth: context.width - HBUIConstants.navigationRailWidth - context.rightPadding - 4.0 * HBSpacing.lg, + columnLength: 2, + fractions: const [ 0.7, 0.3 ], + titles: const [ 'Name', 'Stadt' ], + items: List.generate(pageState.publishers.length, (int index) { + final PublisherEntity publisher = pageState.publishers[index]; + return [ + HBTableText(text: publisher.name), + HBTableText(text: publisher.city ?? '') + ]; + }), + text: _tableText + ) + ) + ] + ) + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/publishers/presentation/widgets/publishers_selection_dialog.dart b/flutter/lib/src/features/publishers/presentation/widgets/publishers_selection_dialog.dart new file mode 100644 index 0000000..b532592 --- /dev/null +++ b/flutter/lib/src/features/publishers/presentation/widgets/publishers_selection_dialog.dart @@ -0,0 +1,241 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:habib_app/core/extensions/object_extension.dart'; +import 'package:habib_app/core/common/widgets/hb_button.dart'; +import 'package:habib_app/core/common/widgets/hb_gap.dart'; +import 'package:habib_app/core/common/widgets/hb_selection_dialog.dart'; +import 'package:habib_app/core/common/widgets/hb_table.dart'; +import 'package:habib_app/core/common/widgets/hb_text_field.dart'; +import 'package:habib_app/core/extensions/context_extension.dart'; +import 'package:habib_app/src/features/publishers/domain/entities/publisher_entity.dart'; +import 'package:habib_app/src/features/publishers/presentation/app/publishers_page_notifier.dart'; +import 'package:habib_app/core/res/hb_icons.dart'; +import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; +import 'package:habib_app/core/services/routes.dart'; +import 'package:habib_app/core/utils/core_utils.dart'; +import 'package:habib_app/core/utils/enums/toast_type.dart'; +import 'package:habib_app/src/features/books/presentation/pages/create_book_page.dart'; + +Future?> showPublishersSelectionDialog({ + required BuildContext context, + required List publishers +}) async { + return await showHBSelectionDialog>( + context: context, + title: 'Verläge wählen', + content: PublishersSelectionDialog(publishers: publishers) + ); +} + +class PublishersSelectionDialog extends StatefulHookConsumerWidget { + + final List publishers; + + const PublishersSelectionDialog({ + super.key, + required this.publishers + }); + + @override + ConsumerState createState() => _PublishersSelectionDialogState(); +} + +class _PublishersSelectionDialogState extends ConsumerState { + + late ValueNotifier> _publishersNotifier; + + final ScrollController _scrollController = ScrollController(); + + late TextEditingController _searchController; + + void _onPageStateUpdate(PublishersPageState? _, PublishersPageState next) { + if (next.hasError) { + CoreUtils.showToast( + context, + type: ToastType.error, + title: next.error!.errorTitle, + description: next.error!.errorDescription, + ); + } + } + + bool get _isBottom { + if (!_scrollController.hasClients) return false; + final double maxScroll = _scrollController.position.maxScrollExtent; + final double currentScroll = _scrollController.offset; + return currentScroll >= maxScroll * 0.9; + } + + void _onScroll() { + if (_isBottom) { + ref.read(publishersPageNotifierProvider.notifier).fetchNextPage(_searchText); + } + } + + HBTableStatus get _tableStatus { + final PublishersPageState pageState = ref.read(publishersPageNotifierProvider); + if (pageState.hasPublishers) return HBTableStatus.data; + if (pageState.hasError || !pageState.hasPublishers) return HBTableStatus.text; + return HBTableStatus.loading; + } + + String? get _tableText { + final PublishersPageState pageState = ref.read(publishersPageNotifierProvider); + if (!pageState.isLoading && !pageState.hasError && !pageState.hasPublishers) return 'Keine Verläge gefunden.'; + if (pageState.hasError) return 'Ein Fehler ist aufgetreten.'; + return null; + } + + String get _searchText { + return _searchController.text.trim(); + } + + Future _onPublisherPressed(int publisherId) async { + await PublisherDetailsRoute(publisherId: publisherId).push(context); + } + + Future _onSearchChanged(String _) async { + await ref.read(publishersPageNotifierProvider.notifier).refresh(_searchText); + } + + Future _onRefresh() async { + await ref.read(publishersPageNotifierProvider.notifier).refresh(_searchText); + } + + void _onRowPressed(PublisherEntity selectedPublisher) async { + if (_publishersNotifier.value.map((CreateBookPublisher publisher) => publisher.id).contains(selectedPublisher.id)) { + _publishersNotifier.value = _publishersNotifier.value.where((CreateBookPublisher publisher) => publisher.id != selectedPublisher.id).toList(); + } else { + final CreateBookPublisher newPublisher = CreateBookPublisher( + id: selectedPublisher.id, + name: selectedPublisher.name, + city: selectedPublisher.city + ); + _publishersNotifier.value = [ ..._publishersNotifier.value, newPublisher ]; + } + } + + void _cancel() { + context.pop(); + } + + void _onChoose() { + context.pop>(_publishersNotifier.value); + } + + @override + void initState() { + super.initState(); + + _scrollController.addListener(_onScroll); + + CoreUtils.postFrameCall(() { + ref.read(publishersPageNotifierProvider.notifier).fetchNextPage(_searchText); + }); + } + + @override + void dispose() { + _scrollController + ..removeListener(_onScroll) + ..dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + + _publishersNotifier = useState>(widget.publishers); + + final PublishersPageState pageState = ref.watch(publishersPageNotifierProvider); + + _searchController = useTextEditingController(); + + ref.listen( + publishersPageNotifierProvider, + _onPageStateUpdate + ); + + return Column( + children: [ + Padding( + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + top: HBSpacing.lg + ), + child: Row( + children: [ + Expanded( + child: HBTextField( + controller: _searchController, + onChanged: _onSearchChanged, + icon: HBIcons.magnifyingGlass, + hint: 'Name' + ) + ), + const HBGap.lg(), + HBButton.shrinkFill( + onPressed: _onRefresh, + icon: HBIcons.arrowPath + ) + ] + ) + ), + Expanded( + child: HBTable( + onPressed: (int index) => _onRowPressed(pageState.publishers[index]), + status: _tableStatus, + padding: EdgeInsets.only( + left: HBSpacing.lg, + right: context.rightPadding + HBSpacing.lg, + bottom: context.bottomPadding + HBSpacing.lg, + top: HBSpacing.xxl + ), + controller: _scrollController, + tableWidth: 800.0 - 4.0 * HBSpacing.lg, + columnLength: 3, + fractions: const [ 0.55, 0.4, 0.05 ], + titles: const [ 'Name', 'Stadt', '' ], + items: List.generate(pageState.publishers.length, (int index) { + final PublisherEntity publisher = pageState.publishers[index]; + final bool isSelected = _publishersNotifier.value.map((CreateBookPublisher publisher) => publisher.id).contains(publisher.id); + return [ + HBTableText( + onPressed: () => _onPublisherPressed(publisher.id), + text: publisher.name + ), + HBTableText(text: publisher.city ?? ''), + HBTableRadioIndicator(isSelected: isSelected) + ]; + }), + text: _tableText + ) + ), + const HBGap.lg(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: HBSpacing.lg), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + HBButton.shrinkFill( + onPressed: _onChoose, + title: 'Wählen' + ), + const HBGap.md(), + HBButton.shrinkOutline( + onPressed: _cancel, + title: 'Abbrechen' + ) + ] + ) + ) + ] + ); + } +} \ No newline at end of file diff --git a/flutter/lib/src/features/settings/presentation/app/settings_page_notifier.dart b/flutter/lib/src/features/settings/presentation/app/settings_page_notifier.dart new file mode 100644 index 0000000..2b14925 --- /dev/null +++ b/flutter/lib/src/features/settings/presentation/app/settings_page_notifier.dart @@ -0,0 +1,157 @@ +import 'package:equatable/equatable.dart'; +import 'package:mysql1/mysql1.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:habib_app/core/services/database.dart'; +import 'package:habib_app/core/utils/validator.dart'; +import 'package:habib_app/core/services/preferences.dart'; + +part 'settings_page_notifier.g.dart'; + +class Settings { + + final String? mySqlHost; + final int? mySqlPort; + final String? mySqlUser; + final String? mySqlPassword; + final String? mySqlDb; + + const Settings({ + this.mySqlHost, + this.mySqlPort, + this.mySqlUser, + this.mySqlPassword, + this.mySqlDb + }); + + factory Settings.fromPreferences(Preferences preferences) { + return Settings( + mySqlHost: preferences.getMySqlHost(), + mySqlPort: preferences.getMySqlPort(), + mySqlUser: preferences.getMySqlUser(), + mySqlPassword: preferences.getMySqlPassword(), + mySqlDb: preferences.getMySqlDb() + ); + } +} + +enum SettingsPageStatus { + initial, + loading, + success, + failure +} + +class SettingsPageState extends Equatable { + + final SettingsPageStatus status; + final Exception? exception; + final Settings settings; + + const SettingsPageState({ + this.status = SettingsPageStatus.initial, + this.exception, + required this.settings + }); + + SettingsPageState copyWith({ + SettingsPageStatus? status, + Exception? exception, + Settings? settings, + bool removeException = false + }) { + return SettingsPageState( + status: status ?? this.status, + exception: removeException ? null : exception ?? this.exception, + settings: settings ?? this.settings + ); + } + + @override + List get props => [ + status, + exception, + settings + ]; +} + +@riverpod +class SettingsPageNotifier extends _$SettingsPageNotifier { + + late final Preferences _preferences; + + @override + SettingsPageState build() { + _preferences = ref.read(preferencesProvider); + return SettingsPageState(settings: Settings.fromPreferences(_preferences)); + } + + Future saveSettings({ + String? mySqlHost, + int? mySqlPort, + String? mySqlUser, + String? mySqlPassword, + String? mySqlDb + }) async { + if (state.status == SettingsPageStatus.loading) return; + + state = state.copyWith( + status: SettingsPageStatus.loading, + removeException: true + ); + + final Settings settings = Settings( + mySqlHost: mySqlHost, + mySqlPort: mySqlPort, + mySqlUser: mySqlUser, + mySqlPassword: mySqlPassword, + mySqlDb: mySqlDb + ); + + try { + Validator.validateSettings(settings); + } catch (exception) { + // TODO + } + + await _preferences.setMySqlHost(settings.mySqlHost!); + await _preferences.setMySqlPort(settings.mySqlPort!); + await _preferences.setMySqlUser(settings.mySqlUser!); + await _preferences.setMySqlPassword(settings.mySqlPassword!); + await _preferences.setMySqlDb(settings.mySqlDb!); + + final ConnectionSettings connectionSettings = ConnectionSettings( + host: settings.mySqlHost!, + port: settings.mySqlPort!, + user: settings.mySqlUser!, + password: settings.mySqlPassword!, + db: settings.mySqlDb! + ); + + late final MySqlConnection connection; + try { + ref.read(mySqlConnectionProvider).close(); + connection = await MySqlConnection.connect(connectionSettings); + } catch (exception) { + /*state = state.copyWith( + status: SettingsPageStatus.failure, + exception: exception + );*/ + return; + } + + databaseProvider.overrideWithValue(Database(connection: connection)); + + state = SettingsPageState( + status: SettingsPageStatus.success, + settings: settings + ); + } +} + + + + + + + diff --git a/flutter/lib/src/features/settings/presentation/pages/settings_page.dart b/flutter/lib/src/features/settings/presentation/pages/settings_page.dart index 8d834cb..4c83d8f 100644 --- a/flutter/lib/src/features/settings/presentation/pages/settings_page.dart +++ b/flutter/lib/src/features/settings/presentation/pages/settings_page.dart @@ -1,61 +1,151 @@ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:habib_app/src/features/settings/presentation/app/settings_page_notifier.dart'; +import 'package:habib_app/core/utils/core_utils.dart'; +import 'package:habib_app/core/utils/enums/toast_type.dart'; import 'package:habib_app/core/common/widgets/hb_app_bar.dart'; import 'package:habib_app/core/common/widgets/hb_gap.dart'; -import 'package:habib_app/core/common/widgets/hb_icon.dart'; import 'package:habib_app/core/common/widgets/hb_scaffold.dart'; -import 'package:habib_app/core/common/widgets/sc_text_field.dart'; +import 'package:habib_app/core/common/widgets/hb_text_field.dart'; import 'package:habib_app/core/extensions/context_extension.dart'; import 'package:habib_app/core/res/hb_icons.dart'; -import 'package:habib_app/core/res/theme/colors/hb_colors.dart'; import 'package:habib_app/core/res/theme/spacing/hb_spacing.dart'; -import 'package:habib_app/core/utils/constants/hb_ui_constants.dart'; -class SettingsPage extends StatelessWidget { +class SettingsPage extends StatefulHookConsumerWidget { const SettingsPage({ super.key }); + @override + ConsumerState createState() => _SettingsPageState(); +} + +class _SettingsPageState extends ConsumerState { + + late TextEditingController _hostController; + late TextEditingController _portController; + late TextEditingController _userController; + late TextEditingController _passwordController; + late TextEditingController _dbController; + + void _onPageStateUpdate(SettingsPageState? _, SettingsPageState next) { + if (next.status == SettingsPageStatus.success) { + CoreUtils.showToast( + context, + type: ToastType.success, + title: 'Speichern erfolgreich', + description: 'Die Einstellungen wurden erfolgreich übernommen.' + ); + } else if (next.status == SettingsPageStatus.failure) { + // TODO + } + } + + Future _onSave() async { + final String mySqlHost = _hostController.text.trim(); + final int mySqlPort = int.parse(_portController.text.trim()); + final String mySqlUser = _userController.text.trim(); + final String mySqlPassword = _passwordController.text.trim(); + final String mySqlDb = _dbController.text.trim(); + + await ref.read(settingsPageNotifierProvider.notifier).saveSettings( + mySqlHost: mySqlHost, + mySqlPort: mySqlPort, + mySqlUser: mySqlUser, + mySqlPassword: mySqlPassword, + mySqlDb: mySqlDb + ); + } + @override Widget build(BuildContext context) { + + final SettingsPageState pageState = ref.watch(settingsPageNotifierProvider); + + _hostController = useTextEditingController(text: pageState.settings.mySqlHost); + _portController = useTextEditingController(text: pageState.settings.mySqlPort?.toString()); + _userController = useTextEditingController(text: pageState.settings.mySqlUser); + _passwordController = useTextEditingController(text: pageState.settings.mySqlPassword); + _dbController = useTextEditingController(text: pageState.settings.mySqlDb); + + ref.listen( + settingsPageNotifierProvider, + _onPageStateUpdate + ); + return HBScaffold( appBar: HBAppBar( context: context, - title: 'Einstellungen' + title: 'Einstellungen', + actionButtons: [ + HBAppBarButton( + onPressed: _onSave, + icon: HBIcons.cloudArrowUp + ) + ] ), body: SingleChildScrollView( padding: EdgeInsets.only( left: HBSpacing.lg, right: context.rightPadding + HBSpacing.lg, - top: HBSpacing.lg, - bottom: context.bottomPadding + HBSpacing.lg + top: HBSpacing.lg ), child: Column( children: [ Row( children: [ - const Expanded( + Expanded( child: HBTextField( - icon: HBIcons.key, - hint: 'Datenbankadresse', - isEnabled: false + title: 'Host', + controller: _hostController, + icon: HBIcons.home ) ), - const HBGap.md(), - SizedBox( - width: HBUIConstants.textFieldButtonSize, - height: HBUIConstants.textFieldButtonSize, - child: RawMaterialButton( - onPressed: () {}, - fillColor: HBColors.gray900, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(HBUIConstants.defaultBorderRadius)), - child: const HBIcon( - icon: HBIcons.pencil, - color: HBColors.gray100, - size: HBUIConstants.textFieldButtonSize * 0.5 - ) + const HBGap.xl(), + Expanded( + child: HBTextField( + title: 'Port', + controller: _portController, + icon: HBIcons.hashtag ) ) ] + ), + const HBGap.xl(), + Row( + children: [ + Expanded( + child: HBTextField( + title: 'Benutzer', + controller: _userController, + icon: HBIcons.user + ) + ), + const HBGap.xl(), + Expanded( + child: HBTextField( + title: 'Passwort', + controller: _passwordController, + icon: HBIcons.key + ) + ) + ] + ), + const HBGap.xl(), + Row( + children: [ + Expanded( + child: HBTextField( + title: 'Datenbankname', + controller: _dbController, + icon: HBIcons.circleStack + ) + ), + const HBGap.md(), + const Spacer() + ] ) ] ) diff --git a/flutter/lib/src/main_page.dart b/flutter/lib/src/main_page.dart index 7e9055a..d2ca76b 100644 --- a/flutter/lib/src/main_page.dart +++ b/flutter/lib/src/main_page.dart @@ -23,8 +23,11 @@ class MainPage extends StatelessWidget { case HomeRoute.location: return 0; case CustomersRoute.location: return 1; case BooksRoute.location: return 2; - case BorrowsRoute.location: return 3; - case SettingsRoute.location: return 4; + case AuthorsRoute.location: return 3; + case PublishersRoute.location: return 4; + case CategoriesRoute.location: return 5; + case BorrowsRoute.location: return 6; + case SettingsRoute.location: return 7; default: throw Exception('Index for location not found: $location'); } } @@ -41,9 +44,18 @@ class MainPage extends StatelessWidget { const BooksRoute().go(context); break; case 3: - const BorrowsRoute().go(context); + const AuthorsRoute().go(context); break; case 4: + const PublishersRoute().go(context); + break; + case 5: + const CategoriesRoute().go(context); + break; + case 6: + const BorrowsRoute().go(context); + break; + case 7: const SettingsRoute().go(context); break; default: throw Exception('Could not navigate to index: $index'); @@ -71,6 +83,18 @@ class MainPage extends StatelessWidget { icon: HBIcons.bookOpen, title: 'Bücher' ), + HBNavigationRailItem( + icon: HBIcons.user, + title: 'Autoren' + ), + HBNavigationRailItem( + icon: HBIcons.home, + title: 'Verlage' + ), + HBNavigationRailItem( + icon: HBIcons.tag, + title: 'Kategorien' + ), HBNavigationRailItem( icon: HBIcons.clock, title: 'Ausleihen' diff --git a/flutter/macos/Flutter/Flutter-Debug.xcconfig b/flutter/macos/Flutter/Flutter-Debug.xcconfig index c2efd0b..4b81f9b 100644 --- a/flutter/macos/Flutter/Flutter-Debug.xcconfig +++ b/flutter/macos/Flutter/Flutter-Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/flutter/macos/Flutter/Flutter-Release.xcconfig b/flutter/macos/Flutter/Flutter-Release.xcconfig index c2efd0b..5caa9d1 100644 --- a/flutter/macos/Flutter/Flutter-Release.xcconfig +++ b/flutter/macos/Flutter/Flutter-Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/flutter/macos/Flutter/GeneratedPluginRegistrant.swift b/flutter/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817..724bb2a 100644 --- a/flutter/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/flutter/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,8 @@ import FlutterMacOS import Foundation +import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/flutter/macos/Podfile b/flutter/macos/Podfile new file mode 100644 index 0000000..c795730 --- /dev/null +++ b/flutter/macos/Podfile @@ -0,0 +1,43 @@ +platform :osx, '10.14' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/flutter/macos/Podfile.lock b/flutter/macos/Podfile.lock new file mode 100644 index 0000000..ed5f725 --- /dev/null +++ b/flutter/macos/Podfile.lock @@ -0,0 +1,23 @@ +PODS: + - FlutterMacOS (1.0.0) + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - FlutterMacOS (from `Flutter/ephemeral`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + +EXTERNAL SOURCES: + FlutterMacOS: + :path: Flutter/ephemeral + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + +SPEC CHECKSUMS: + FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + +PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367 + +COCOAPODS: 1.14.3 diff --git a/flutter/macos/Runner.xcodeproj/project.pbxproj b/flutter/macos/Runner.xcodeproj/project.pbxproj index 80f922b..b7fec68 100644 --- a/flutter/macos/Runner.xcodeproj/project.pbxproj +++ b/flutter/macos/Runner.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 54220D066D9A6BCEA775657C /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0F366FA68396015071927091 /* Pods_RunnerTests.framework */; }; + FB4EB9EBA947FF47E121C136 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92FE42937F72A5C170D4DAF1 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,6 +62,10 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0DD789C29C699CBEE0894BD3 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 0E15317930AB3EC655D73B34 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 0F366FA68396015071927091 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 27DC34D1E7C6C8F50BC6EE53 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; @@ -76,8 +82,12 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 5F39A3635ED21FF61F4DE003 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 754E850A1861F6DC62B958EB /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 92FE42937F72A5C170D4DAF1 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + B859E6BD043F129DBB48FBFA /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 54220D066D9A6BCEA775657C /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -92,6 +103,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + FB4EB9EBA947FF47E121C136 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -125,6 +137,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + 92268027886F554DE5D05EBF /* Pods */, ); sourceTree = ""; }; @@ -172,9 +185,25 @@ path = Runner; sourceTree = ""; }; + 92268027886F554DE5D05EBF /* Pods */ = { + isa = PBXGroup; + children = ( + 754E850A1861F6DC62B958EB /* Pods-Runner.debug.xcconfig */, + 0E15317930AB3EC655D73B34 /* Pods-Runner.release.xcconfig */, + 27DC34D1E7C6C8F50BC6EE53 /* Pods-Runner.profile.xcconfig */, + 5F39A3635ED21FF61F4DE003 /* Pods-RunnerTests.debug.xcconfig */, + B859E6BD043F129DBB48FBFA /* Pods-RunnerTests.release.xcconfig */, + 0DD789C29C699CBEE0894BD3 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + 92FE42937F72A5C170D4DAF1 /* Pods_Runner.framework */, + 0F366FA68396015071927091 /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -186,6 +215,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + D1BB4DE7B8A9A531607A4E44 /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -204,11 +234,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + FD8ABA8F0FCF2729DAC18102 /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, + 91A030F5917CD979F1F78FF2 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -329,6 +361,67 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; + 91A030F5917CD979F1F78FF2 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + D1BB4DE7B8A9A531607A4E44 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + FD8ABA8F0FCF2729DAC18102 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -380,6 +473,7 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 5F39A3635ED21FF61F4DE003 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -394,6 +488,7 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = B859E6BD043F129DBB48FBFA /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -408,6 +503,7 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 0DD789C29C699CBEE0894BD3 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; diff --git a/flutter/macos/Runner.xcworkspace/contents.xcworkspacedata b/flutter/macos/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/flutter/macos/Runner.xcworkspace/contents.xcworkspacedata +++ b/flutter/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index dc9a7a8..2d2d5ac 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -49,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" + board_datetime_picker: + dependency: "direct main" + description: + name: board_datetime_picker + sha256: f85d26d0fb40656fab0855d8f6dc3b8c8adc293bf62f95d68feacd8c30fda044 + url: "https://pub.dev" + source: hosted + version: "1.6.8" boolean_selector: dependency: transitive description: @@ -249,6 +257,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21" + url: "https://pub.dev" + source: hosted + version: "2.1.2" file: dependency: transitive description: @@ -448,6 +464,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.7" + intl: + dependency: transitive + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" io: dependency: transitive description: @@ -584,6 +608,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" + url: "https://pub.dev" + source: hosted + version: "2.2.1" pausable_timer: dependency: transitive description: @@ -600,6 +648,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" pool: dependency: transitive description: @@ -672,6 +736,62 @@ packages: url: "https://pub.dev" source: hosted version: "0.27.7" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180 + url: "https://pub.dev" + source: hosted + version: "2.2.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "1ee8bf911094a1b592de7ab29add6f826a7331fb854273d55918693d5364a1f2" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "0a8a893bf4fd1152f93fec03a415d11c27c74454d96e2318a7ac38dd18683ab7" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "22e2ecac9419b4246d7c22bfbbda589e3acf5c0351137d87dd2939d984d37c3b" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: "9aee1089b36bd2aafe06582b7d7817fd317ef05fc30e6ba14bff247d0933042a" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59" + url: "https://pub.dev" + source: hosted + version: "2.3.2" shelf: dependency: transitive description: @@ -765,6 +885,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + syncfusion_flutter_charts: + dependency: "direct main" + description: + name: syncfusion_flutter_charts + sha256: "0d7fc75b27269674925732f95ad7cc055a39fa566b28598ce6b1e1cfcafa9faa" + url: "https://pub.dev" + source: hosted + version: "26.1.38" + syncfusion_flutter_core: + dependency: transitive + description: + name: syncfusion_flutter_core + sha256: "2cd3a1c59bd9c2863eee96cc5400429ecc7b42cd1e7379f3eb5ccd9fc2e9306b" + url: "https://pub.dev" + source: hosted + version: "26.1.38" term_glyph: dependency: transitive description: @@ -877,6 +1013,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.5" + win32: + dependency: transitive + description: + name: win32 + sha256: a79dbe579cb51ecd6d30b17e0cae4e0ea15e2c0e66f69ad4198f22a6789e94f4 + url: "https://pub.dev" + source: hosted + version: "5.5.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + url: "https://pub.dev" + source: hosted + version: "1.0.4" xml: dependency: transitive description: @@ -894,5 +1046,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.3.3 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + dart: ">=3.4.0 <4.0.0" + flutter: ">=3.19.0" diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 761d7f4..92b0f6c 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -1,7 +1,7 @@ name: habib_app description: "A new Flutter project." publish_to: 'none' -version: 0.0.1 +version: 0.9.0 environment: sdk: '>=3.3.3 <4.0.0' @@ -14,6 +14,8 @@ dependencies: flutter_dotenv: ^5.1.0 + shared_preferences: ^2.2.2 + mysql1: ^0.20.0 hooks_riverpod: ^2.4.6 @@ -26,6 +28,10 @@ dependencies: flutter_svg: ^2.0.10+1 + syncfusion_flutter_charts: ^26.1.38 + + board_datetime_picker: ^1.6.8 + gap: ^3.0.1 toastification: ^1.2.1 @@ -89,7 +95,7 @@ msix_config: publisher_display_name: Berufskolleg am Haspel identity_name: dev.fleeser.habib logo_path: ../installers/Windows/app_icon.png - msix_version: 0.0.1.0 + msix_version: 0.9.0.0 languages: de-de output_path: ../installers/Windows output_name: Habib-Installer \ No newline at end of file diff --git a/mysql/seed.sql b/mysql/seed.sql index f15a20c..52e35e1 100644 --- a/mysql/seed.sql +++ b/mysql/seed.sql @@ -1,3 +1,5 @@ +USE public; + INSERT INTO addresses (city, postal_code, street) VALUES ('Berlin', '10115', 'Unter den Linden 1'), ('Hamburg', '20095', 'Mönckebergstraße 10'), @@ -209,57 +211,57 @@ INSERT INTO authors (first_name, last_name, title) VALUES ('Samuel', 'Beckett', NULL), ('Vladimir', 'Nabokov', NULL); -INSERT INTO books (title, isbn_10, isbn_13, edition, publish_date, publisher_id, bought) VALUES -('1984', '0451524934', '9780451524935', 1, '1949-06-08', 1, 1), -('To Kill a Mockingbird', '0060935464', '9780060935467', 1, '1960-07-11', 2, 1), -('The Great Gatsby', '0743273567', '9780743273565', 1, '1925-04-10', 3, 1), -('Moby Dick', NULL, '9781503280786', NULL, '1851-10-18', 4, 0), -('Pride and Prejudice', '0679783261', '9780679783268', 1, '1813-01-28', 5, 1), -('War and Peace', '0199232768', '9780199232765', NULL, '1869-01-01', 6, 0), -('Crime and Punishment', '0143058142', '9780143058144', NULL, '1866-01-01', 7, 1), -('The Catcher in the Rye', '0316769487', '9780316769488', 1, '1951-07-16', 8, 1), -('Brave New World', '0060850523', '9780060850524', NULL, '1932-08-01', 9, 1), -('The Hobbit', '054792822X', '9780547928227', 1, '1937-09-21', 10, 1), -('Fahrenheit 451', '1451673310', '9781451673319', NULL, '1953-10-19', 11, 0), -('Jane Eyre', '0142437204', '9780142437209', 1, '1847-10-16', 12, 1), -('Wuthering Heights', '0141439556', '9780141439556', 1, '1847-12-01', 13, 0), -('Animal Farm', '0451526341', '9780451526342', 1, '1945-08-17', 14, 1), -('The Odyssey', NULL, NULL, NULL, '800-01-01', 15, 0), -('Les Misérables', '045141943X', '9780451419439', 1, '1862-01-01', 16, 1), -('The Brothers Karamazov', '0374528373', '9780374528379', NULL, '1880-01-01', 17, 0), -('The Divine Comedy', NULL, '9780142437223', 1, '1320-01-01', 18, 1), -('Hamlet', '0140714545', '9780140714548', NULL, '1603-01-01', 19, 1), -('The Iliad', NULL, NULL, NULL, '750-01-01', 20, 0), -('A Tale of Two Cities', '0486406512', '9780486406510', NULL, '1859-04-30', 21, 1), -('Don Quixote', '0060934344', '9780060934347', 1, '1605-01-16', 22, 1), -('One Hundred Years of Solitude', '0060883286', '9780060883287', NULL, '1967-05-30', 23, 1), -('The Picture of Dorian Gray', '0141439572', '9780141439570', 1, '1890-06-20', 24, 1), -('Ulysses', '0199535671', '9780199535675', 1, '1922-02-02', 25, 0), -('The Count of Monte Cristo', '0140449264', '9780140449266', 1, '1844-01-01', 26, 1), -('Frankenstein', '0486282112', '9780486282114', NULL, '1818-01-01', 27, 1), -('Dracula', '0486411095', '9780486411095', 1, '1897-05-26', 28, 1), -('The Sun Also Rises', '0743297334', '9780743297332', 1, '1926-10-22', 29, 1), -('Gone with the Wind', '1451635621', '9781451635621', NULL, '1936-06-30', 30, 0), -('The Lord of the Rings', NULL, '9780544003415', NULL, '1954-07-29', 31, 1), -('The Old Man and the Sea', '0684830493', '9780684830490', 1, '1952-09-01', 32, 1), -('Lolita', '0679723161', '9780679723165', 1, '1955-09-15', 33, 1), -('The Scarlet Letter', '0486280489', '9780486280489', 1, '1850-03-16', 34, 0), -('Moby-Dick', '1503280780', '9781503280786', NULL, '1851-10-18', 35, 0), -('Anna Karenina', '0143035002', '9780143035008', 1, '1877-01-01', 36, 1), -('Catch-22', '1451626657', '9781451626650', NULL, '1961-11-10', 37, 0), -('Slaughterhouse-Five', '0385333846', '9780385333849', NULL, '1969-03-31', 38, 1), -('The Grapes of Wrath', '0143039431', '9780143039433', 1, '1939-04-14', 39, 1), -('Brave New World', '0060850523', '9780060850524', NULL, '1932-08-01', 40, 1), -('The Adventures of Huckleberry Finn', '0486280616', '9780486280618', 1, '1884-12-10', 41, 0), -('Of Mice and Men', '0140177396', '9780140177397', 1, '1937-02-06', 42, 1), -('A Clockwork Orange', '0393312836', '9780393312836', NULL, '1962-01-01', 43, 0), -('Beloved', '1400033411', '9781400033416', NULL, '1987-09-16', 44, 1), -('Invisible Man', '0679732764', '9780679732761', 1, '1952-04-14', 45, 0), -('Gone Girl', '0307588378', '9780307588371', 1, '2012-06-05', 46, 1), -('The Road', '0307387895', '9780307387899', 1, '2006-09-26', 47, 1), -('Life of Pi', '0156027321', '9780156027328', 1, '2001-09-11', 48, 1), -('The Handmaids Tale', '038549081X', '9780385490818', 1, '1985-04-01', 49, 1), -('The Kite Runner', '1594480001', '9781594480003', 1, '2003-05-29', 50, 1); +INSERT INTO books (title, isbn_10, isbn_13, edition, publish_date, bought) VALUES +('1984', '0451524934', '9780451524935', 1, '1949-06-08', 1), +('To Kill a Mockingbird', '0060935464', '9780060935467', 1, '1960-07-11', 1), +('The Great Gatsby', '0743273567', '9780743273565', 1, '1925-04-10', 1), +('Moby Dick', NULL, '9781503280786', NULL, '1851-10-18', 0), +('Pride and Prejudice', '0679783261', '9780679783268', 1, '1813-01-28', 1), +('War and Peace', '0199232768', '9780199232765', NULL, '1869-01-01', 0), +('Crime and Punishment', '0143058142', '9780143058144', NULL, '1866-01-01', 1), +('The Catcher in the Rye', '0316769487', '9780316769488', 1, '1951-07-16', 1), +('Brave New World', '0060850523', '9780060850524', NULL, '1932-08-01', 1), +('The Hobbit', '054792822X', '9780547928227', 1, '1937-09-21', 1), +('Fahrenheit 451', '1451673310', '9781451673319', NULL, '1953-10-19', 0), +('Jane Eyre', '0142437204', '9780142437209', 1, '1847-10-16', 1), +('Wuthering Heights', '0141439556', '9780141439556', 1, '1847-12-01', 0), +('Animal Farm', '0451526341', '9780451526342', 1, '1945-08-17', 1), +('The Odyssey', NULL, NULL, NULL, '800-01-01', 0), +('Les Misérables', '045141943X', '9780451419439', 1, '1862-01-01', 1), +('The Brothers Karamazov', '0374528373', '9780374528379', NULL, '1880-01-01', 0), +('The Divine Comedy', NULL, '9780142437223', 1, '1320-01-01', 1), +('Hamlet', '0140714545', '9780140714548', NULL, '1603-01-01', 1), +('The Iliad', NULL, NULL, NULL, '750-01-01', 0), +('A Tale of Two Cities', '0486406512', '9780486406510', NULL, '1859-04-30', 1), +('Don Quixote', '0060934344', '9780060934347', 1, '1605-01-16', 1), +('One Hundred Years of Solitude', '0060883286', '9780060883287', NULL, '1967-05-30', 1), +('The Picture of Dorian Gray', '0141439572', '9780141439570', 1, '1890-06-20', 1), +('Ulysses', '0199535671', '9780199535675', 1, '1922-02-02', 0), +('The Count of Monte Cristo', '0140449264', '9780140449266', 1, '1844-01-01', 1), +('Frankenstein', '0486282112', '9780486282114', NULL, '1818-01-01', 1), +('Dracula', '0486411095', '9780486411095', 1, '1897-05-26', 1), +('The Sun Also Rises', '0743297334', '9780743297332', 1, '1926-10-22', 1), +('Gone with the Wind', '1451635621', '9781451635621', NULL, '1936-06-30', 0), +('The Lord of the Rings', NULL, '9780544003415', NULL, '1954-07-29', 1), +('The Old Man and the Sea', '0684830493', '9780684830490', 1, '1952-09-01', 1), +('Lolita', '0679723161', '9780679723165', 1, '1955-09-15', 1), +('The Scarlet Letter', '0486280489', '9780486280489', 1, '1850-03-16', 0), +('Moby-Dick', '1503280780', '9781503280786', NULL, '1851-10-18', 0), +('Anna Karenina', '0143035002', '9780143035008', 1, '1877-01-01', 1), +('Catch-22', '1451626657', '9781451626650', NULL, '1961-11-10', 0), +('Slaughterhouse-Five', '0385333846', '9780385333849', NULL, '1969-03-31', 1), +('The Grapes of Wrath', '0143039431', '9780143039433', 1, '1939-04-14', 1), +('Brave New World', '0060850523', '9780060850524', NULL, '1932-08-01', 1), +('The Adventures of Huckleberry Finn', '0486280616', '9780486280618', 1, '1884-12-10', 0), +('Of Mice and Men', '0140177396', '9780140177397', 1, '1937-02-06', 1), +('A Clockwork Orange', '0393312836', '9780393312836', NULL, '1962-01-01', 0), +('Beloved', '1400033411', '9781400033416', NULL, '1987-09-16', 1), +('Invisible Man', '0679732764', '9780679732761', 1, '1952-04-14', 0), +('Gone Girl', '0307588378', '9780307588371', 1, '2012-06-05', 1), +('The Road', '0307387895', '9780307387899', 1, '2006-09-26', 1), +('Life of Pi', '0156027321', '9780156027328', 1, '2001-09-11', 1), +('The Handmaids Tale', '038549081X', '9780385490818', 1, '1985-04-01', 1), +('The Kite Runner', '1594480001', '9781594480003', 1, '2003-05-29', 1); INSERT INTO categories (name) VALUES ('Fiction'), @@ -476,4 +478,56 @@ INSERT INTO book_categories (book_id, category_id) VALUES (29, 27), (30, 28), (31, 29), -(32, 30); \ No newline at end of file +(32, 30); + +INSERT INTO book_publishers (book_id, publisher_id) VALUES +(1, 1), +(2, 2), +(3, 3), +(4, 4), +(5, 5), +(6, 6), +(7, 7), +(8, 8), +(9, 9), +(10, 10), +(11, 11), +(12, 12), +(13, 13), +(14, 14), +(15, 15), +(16, 16), +(17, 17), +(18, 18), +(19, 19), +(20, 20), +(21, 21), +(22, 22), +(23, 23), +(24, 24), +(25, 25), +(26, 26), +(27, 27), +(28, 28), +(29, 29), +(30, 30), +(31, 31), +(32, 32), +(33, 33), +(34, 34), +(35, 35), +(36, 36), +(37, 37), +(38, 38), +(39, 39), +(40, 40), +(41, 41), +(42, 42), +(43, 43), +(44, 44), +(45, 45), +(46, 46), +(47, 47), +(48, 48), +(49, 49), +(50, 50); \ No newline at end of file diff --git a/mysql/setup.sql b/mysql/setup.sql index ca3adaa..fcba1c4 100644 --- a/mysql/setup.sql +++ b/mysql/setup.sql @@ -55,10 +55,9 @@ CREATE TABLE books ( isbn_13 nchar(13), edition SMALLINT, publish_date DATE, - publisher_id MEDIUMINT NOT NULL, bought SMALLINT, + received_at DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT pk_book_id PRIMARY KEY (id), - CONSTRAINT fk_books_publisher_id FOREIGN KEY (publisher_id) REFERENCES publishers(id) ON UPDATE CASCADE ON DELETE CASCADE, CONSTRAINT book_bought_0_or_1 CHECK (bought IN (0, 1)) ); @@ -101,4 +100,14 @@ CREATE TABLE book_categories ( CONSTRAINT pk_book_categories_book_id_category_id PRIMARY KEY (book_id, category_id), CONSTRAINT fk_book_categories_book_id FOREIGN KEY (book_id) REFERENCES books(id) ON UPDATE CASCADE ON DELETE CASCADE, CONSTRAINT fk_book_categories_category_id FOREIGN KEY (category_id) REFERENCES categories(id) ON UPDATE CASCADE ON DELETE CASCADE +); + +CREATE TABLE book_publishers ( + book_id MEDIUMINT NOT NULL, + publisher_id MEDIUMINT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT pk_book_publishers_book_id_publisher_id PRIMARY KEY (book_id, publisher_id), + CONSTRAINT fk_book_publishers_book_id FOREIGN KEY (book_id) REFERENCES books(id) ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT fk_book_publishers_publisher_id FOREIGN KEY (publisher_id) REFERENCES publishers(id) ON UPDATE CASCADE ON DELETE CASCADE ); \ No newline at end of file diff --git a/mysql/statistics.sql b/mysql/statistics.sql new file mode 100644 index 0000000..f408956 --- /dev/null +++ b/mysql/statistics.sql @@ -0,0 +1,71 @@ +# Anzahl der verliehenen Bücher + +WITH months AS ( + SELECT 1 AS month + UNION ALL SELECT 2 + UNION ALL SELECT 3 + UNION ALL SELECT 4 + UNION ALL SELECT 5 + UNION ALL SELECT 6 + UNION ALL SELECT 7 + UNION ALL SELECT 8 + UNION ALL SELECT 9 + UNION ALL SELECT 10 + UNION ALL SELECT 11 + UNION ALL SELECT 12 +) +SELECT + m.month, + COUNT(b.book_id) AS books_count +FROM + months m +LEFT JOIN ( + SELECT + MONTH(borrows.created_at) AS month, + borrows.book_id + FROM + borrows + WHERE + YEAR(borrows.created_at) = 2024 +) b ON m.month = b.month +GROUP BY + m.month +ORDER BY + m.month; + +# Wie viele von den neuen Büchern sind gekauft / geschenkt + +WITH months AS ( + SELECT 1 AS month + UNION ALL SELECT 2 + UNION ALL SELECT 3 + UNION ALL SELECT 4 + UNION ALL SELECT 5 + UNION ALL SELECT 6 + UNION ALL SELECT 7 + UNION ALL SELECT 8 + UNION ALL SELECT 9 + UNION ALL SELECT 10 + UNION ALL SELECT 11 + UNION ALL SELECT 12 +) +SELECT + m.month, + COALESCE(b.bought_0_count, 0) AS bought_0_count, + COALESCE(b.bought_1_count, 0) AS bought_1_count +FROM + months m +LEFT JOIN ( + SELECT + MONTH(books.received_at) AS month, + SUM(CASE WHEN books.bought = 0 THEN 1 ELSE 0 END) AS bought_0_count, + SUM(CASE WHEN books.bought = 1 THEN 1 ELSE 0 END) AS bought_1_count + FROM + books + WHERE + YEAR(books.received_at) = 2024 + GROUP BY + MONTH(books.received_at) +) b ON m.month = b.month +ORDER BY + m.month; \ No newline at end of file