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!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Name 1
+
+
+
+
Name 2
+
+
+
+
Name 3
+
+
+
+
+
+
+
+
+
+
+
+ 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