diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e191c2a..b3f115d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,12 +6,27 @@ version: 2 updates: - package-ecosystem: "pub" - directory: "/packages/flutter_timeline" + directory: "/packages/dart_feed_utilities" schedule: interval: "weekly" - + - package-ecosystem: "pub" - directory: "/packages/flutter_timeline_interface" + directory: "/packages/flutter_catalog" + schedule: + interval: "weekly" + + - package-ecosystem: "pub" + directory: "/packages/flutter_catalog_interface" + schedule: + interval: "weekly" + + - package-ecosystem: "pub" + directory: "/packages/flutter_catalog_rest_api" + schedule: + interval: "weekly" + + - package-ecosystem: "pub" + directory: "/packages/flutter_catalog_riverpod" schedule: interval: "weekly" @@ -19,3 +34,13 @@ updates: directory: "/packages/flutter_feed_utils" schedule: interval: "weekly" + + - package-ecosystem: "pub" + directory: "/packages/flutter_timeline" + schedule: + interval: "weekly" + + - package-ecosystem: "pub" + directory: "/packages/flutter_timeline_interface" + schedule: + interval: "weekly" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4e5f0ea --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,14 @@ +name: Iconica Standard Component Release Workflow +# Workflow Caller version: 1.0.0 + +on: + release: + types: [published] + + workflow_dispatch: + +jobs: + call-global-iconica-workflow: + uses: Iconica-Development/.github/.github/workflows/component-release.yml@master + secrets: inherit + permissions: write-all \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cf1a33f..47c7ee0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ repos: hooks: - id: dart-format args: - - packages/dart_feed_utils/lib/* + - packages/dart_feed_utilities/lib/* - packages/flutter_feed_utils/lib/* - packages/flutter_timeline/lib/* - packages/flutter_timeline/example/lib/* diff --git a/packages/flutter_catalog/.gitignore b/packages/flutter_catalog/.gitignore new file mode 100644 index 0000000..747690c --- /dev/null +++ b/packages/flutter_catalog/.gitignore @@ -0,0 +1,43 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/packages/flutter_catalog/analysis_options.yaml b/packages/flutter_catalog/analysis_options.yaml new file mode 100644 index 0000000..ae3d307 --- /dev/null +++ b/packages/flutter_catalog/analysis_options.yaml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2025 Iconica +# +# SPDX-License-Identifier: GPL-3.0-or-later + +include: package:flutter_iconica_analysis/components_options.yaml + +analyzer: + exclude: [lib/l10n/*.dart] + +linter: + rules: diff --git a/packages/flutter_catalog/example/.gitignore b/packages/flutter_catalog/example/.gitignore new file mode 100644 index 0000000..203f074 --- /dev/null +++ b/packages/flutter_catalog/example/.gitignore @@ -0,0 +1,48 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +app_localizations_en.dart +app_localizations.dart \ No newline at end of file diff --git a/packages/flutter_catalog/example/analysis_options.yaml b/packages/flutter_catalog/example/analysis_options.yaml new file mode 100644 index 0000000..2d6d041 --- /dev/null +++ b/packages/flutter_catalog/example/analysis_options.yaml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2025 Iconica +# +# SPDX-License-Identifier: GPL-3.0-or-later + +include: package:flutter_iconica_analysis/analysis_options.yaml + +analyzer: + exclude: [lib/l10n/*.dart] + +linter: + rules: diff --git a/packages/flutter_catalog/example/l10n.yaml b/packages/flutter_catalog/example/l10n.yaml new file mode 100644 index 0000000..0cdc92d --- /dev/null +++ b/packages/flutter_catalog/example/l10n.yaml @@ -0,0 +1,4 @@ +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart +synthetic-package: false diff --git a/packages/flutter_catalog/example/lib/l10n/app_en.arb b/packages/flutter_catalog/example/lib/l10n/app_en.arb new file mode 100644 index 0000000..9dae4a2 --- /dev/null +++ b/packages/flutter_catalog/example/lib/l10n/app_en.arb @@ -0,0 +1,3 @@ +{ + "@@locale": "en" +} \ No newline at end of file diff --git a/packages/flutter_catalog/example/lib/main.dart b/packages/flutter_catalog/example/lib/main.dart new file mode 100644 index 0000000..5b7bde4 --- /dev/null +++ b/packages/flutter_catalog/example/lib/main.dart @@ -0,0 +1,52 @@ +import "package:flutter/material.dart"; +import "package:flutter_catalog/flutter_catalog.dart"; +import "package:flutter_catalog_example/l10n/app_localizations.dart"; + +void main() { + runApp(const CatalogExampleApp()); +} + +class CatalogExampleApp extends StatelessWidget { + const CatalogExampleApp({super.key}); + + @override + Widget build(BuildContext context) => MaterialApp( + title: "Flutter Catalog", + localizationsDelegates: const [ + ...AppLocalizations.localizationsDelegates, + ...FlutterCatalogLocalizations.localizationsDelegates, + ], + supportedLocales: const [Locale("en")], + theme: ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFFBDE260), + ), + appBarTheme: const AppBarTheme( + backgroundColor: Color(0xFFBDE260), + foregroundColor: Colors.black, + ), + ), + home: const CatalogView(), + ); +} + +class CatalogView extends StatelessWidget { + const CatalogView({ + super.key, + }); + + @override + Widget build(BuildContext context) => FlutterCatalogUserstory( + userId: "example_user", + options: CatalogOptions( + builders: CatalogBuilders( + baseScreenBuilder: (context, screenType, appBar, title, body) => + Scaffold( + appBar: appBar, + body: body, + ), + ), + ), + ); +} diff --git a/packages/flutter_catalog/example/pubspec.yaml b/packages/flutter_catalog/example/pubspec.yaml new file mode 100644 index 0000000..0e8a42d --- /dev/null +++ b/packages/flutter_catalog/example/pubspec.yaml @@ -0,0 +1,29 @@ +name: flutter_catalog_example +description: "Flutter Feed Catalog Example Application" +publish_to: "none" + +version: 1.0.0+1 + +environment: + sdk: ">=3.4.0 <4.0.0" +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + flutter_catalog: + path: ../ + +dependency_overrides: + flutter_hooks: 0.20.4 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_iconica_analysis: + hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub + version: ^7.0.0 + +flutter: + uses-material-design: true + generate: true diff --git a/packages/flutter_catalog/example/test/widget_test.dart b/packages/flutter_catalog/example/test/widget_test.dart new file mode 100644 index 0000000..f312063 --- /dev/null +++ b/packages/flutter_catalog/example/test/widget_test.dart @@ -0,0 +1,15 @@ +// This is an example unit test. +// +// A unit test tests a single function, method, or class. To learn more about +// writing unit tests, visit +// https://flutter.dev/docs/cookbook/testing/unit/introduction + +import "package:flutter_test/flutter_test.dart"; + +void main() { + group("Plus Operator", () { + test("should add two numbers together", () { + expect(1 + 1, 2); + }); + }); +} diff --git a/packages/flutter_catalog/l10n.yaml b/packages/flutter_catalog/l10n.yaml new file mode 100644 index 0000000..51fb5bd --- /dev/null +++ b/packages/flutter_catalog/l10n.yaml @@ -0,0 +1,5 @@ +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart +synthetic-package: false +output-class: "FlutterCatalogLocalizations" diff --git a/packages/flutter_catalog/lib/flutter_catalog.dart b/packages/flutter_catalog/lib/flutter_catalog.dart new file mode 100644 index 0000000..0d4d1b8 --- /dev/null +++ b/packages/flutter_catalog/lib/flutter_catalog.dart @@ -0,0 +1,21 @@ +/// A library that provides a complete catalog user story. +/// +/// Includes views for Browse, filtering, and viewing item details. +library flutter_catalog; + +export "package:flutter_catalog_interface/flutter_catalog_interface.dart"; + +export "/l10n/app_localizations.dart"; +export "src/config/catalog_builders.dart"; +export "src/config/catalog_filter_options.dart"; +export "src/config/catalog_options.dart"; +export "src/config/catalog_theme.dart"; +export "src/config/catalog_translations.dart"; +export "src/config/screen_types.dart"; +export "src/flutter_catalog_userstory.dart"; +export "src/repositories/memory_catalog_repository.dart"; +export "src/services/catalog_service.dart"; +export "src/services/pop_handler.dart"; +export "src/utils/scope.dart"; +export "src/views/views.dart"; +export "src/widgets/catalog_grid_item.dart"; diff --git a/packages/flutter_catalog/lib/l10n/app_en.arb b/packages/flutter_catalog/lib/l10n/app_en.arb new file mode 100644 index 0000000..add84ed --- /dev/null +++ b/packages/flutter_catalog/lib/l10n/app_en.arb @@ -0,0 +1,40 @@ +{ + "@@locale": "en", + "overviewTitle": "Toys near you", + "filtersTitle": "Filters", + "searchHint": "Find toys...", + "filterButton": "Filters", + "noItemsFound": "No items found.", + "itemLoadingError": "Failed to load items.", + "itemCreatePageMandatorySection": "Mandatory", + "detailDescriptionTitle": "Description", + "applyFiltersButton": "Apply filters", + "sendMessageButton": "Send message", + "characteristicsTitle": "Characteristics", + "distanceTitle": "Distance", + "postedSince": "Posted since {date}", + "@postedSince": { + "placeholders": { + "date": {} + } + }, + "editItemButton": "Edit", + "contactUserDisabledMessage": "You cannot contact the author of this item.", + "itemCreatePageTitle": "Create Item", + "priceFree": "Free", + "itemEditPageTitle": "Edit Item", + "itemCreatePageTitleHint": "Title", + "itemCreatePageDescriptionHint": "Description", + "itemCreatePageTitleRequiredError": "Title is required.", + "itemCreatePageAddImagesButton": "Add Images", + "itemCreatePageSaveChangesButton": "Save Changes", + "itemCreatePageSavingChangesButton": "Saving...", + "itemCreatePageDeleteItemButton": "Delete Item", + "itemCreatePageDeleteConfirmationTitle": "Delete Item", + "itemCreatePageDeleteConfirmationMessage": "Are you sure you want to delete this item forever?", + "itemCreatePageDeleteConfirmationConfirm": "Delete", + "itemCreatePageDeleteConfirmationCancel": "Cancel", + "itemCreatePageGenericError": "An error occurred.", + "itemCreatePageItemDeletedSuccess": "Item deleted successfully.", + "itemCreatePageItemDeleteError": "Failed to delete item." +} \ No newline at end of file diff --git a/packages/flutter_catalog/lib/l10n/app_localizations.dart b/packages/flutter_catalog/lib/l10n/app_localizations.dart new file mode 100644 index 0000000..343ab2a --- /dev/null +++ b/packages/flutter_catalog/lib/l10n/app_localizations.dart @@ -0,0 +1,319 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_en.dart'; + +/// Callers can lookup localized strings with an instance of FlutterCatalogLocalizations +/// returned by `FlutterCatalogLocalizations.of(context)`. +/// +/// Applications need to include `FlutterCatalogLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: FlutterCatalogLocalizations.localizationsDelegates, +/// supportedLocales: FlutterCatalogLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the FlutterCatalogLocalizations.supportedLocales +/// property. +abstract class FlutterCatalogLocalizations { + FlutterCatalogLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static FlutterCatalogLocalizations? of(BuildContext context) { + return Localizations.of( + context, FlutterCatalogLocalizations); + } + + static const LocalizationsDelegate delegate = + _FlutterCatalogLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [Locale('en')]; + + /// No description provided for @overviewTitle. + /// + /// In en, this message translates to: + /// **'Toys near you'** + String get overviewTitle; + + /// No description provided for @filtersTitle. + /// + /// In en, this message translates to: + /// **'Filters'** + String get filtersTitle; + + /// No description provided for @searchHint. + /// + /// In en, this message translates to: + /// **'Find toys...'** + String get searchHint; + + /// No description provided for @filterButton. + /// + /// In en, this message translates to: + /// **'Filters'** + String get filterButton; + + /// No description provided for @noItemsFound. + /// + /// In en, this message translates to: + /// **'No items found.'** + String get noItemsFound; + + /// No description provided for @itemLoadingError. + /// + /// In en, this message translates to: + /// **'Failed to load items.'** + String get itemLoadingError; + + /// No description provided for @itemCreatePageMandatorySection. + /// + /// In en, this message translates to: + /// **'Mandatory'** + String get itemCreatePageMandatorySection; + + /// No description provided for @detailDescriptionTitle. + /// + /// In en, this message translates to: + /// **'Description'** + String get detailDescriptionTitle; + + /// No description provided for @applyFiltersButton. + /// + /// In en, this message translates to: + /// **'Apply filters'** + String get applyFiltersButton; + + /// No description provided for @sendMessageButton. + /// + /// In en, this message translates to: + /// **'Send message'** + String get sendMessageButton; + + /// No description provided for @characteristicsTitle. + /// + /// In en, this message translates to: + /// **'Characteristics'** + String get characteristicsTitle; + + /// No description provided for @distanceTitle. + /// + /// In en, this message translates to: + /// **'Distance'** + String get distanceTitle; + + /// No description provided for @postedSince. + /// + /// In en, this message translates to: + /// **'Posted since {date}'** + String postedSince(Object date); + + /// No description provided for @editItemButton. + /// + /// In en, this message translates to: + /// **'Edit'** + String get editItemButton; + + /// No description provided for @contactUserDisabledMessage. + /// + /// In en, this message translates to: + /// **'You cannot contact the author of this item.'** + String get contactUserDisabledMessage; + + /// No description provided for @itemCreatePageTitle. + /// + /// In en, this message translates to: + /// **'Create Item'** + String get itemCreatePageTitle; + + /// No description provided for @priceFree. + /// + /// In en, this message translates to: + /// **'Free'** + String get priceFree; + + /// No description provided for @itemEditPageTitle. + /// + /// In en, this message translates to: + /// **'Edit Item'** + String get itemEditPageTitle; + + /// No description provided for @itemCreatePageTitleHint. + /// + /// In en, this message translates to: + /// **'Title'** + String get itemCreatePageTitleHint; + + /// No description provided for @itemCreatePageDescriptionHint. + /// + /// In en, this message translates to: + /// **'Description'** + String get itemCreatePageDescriptionHint; + + /// No description provided for @itemCreatePageTitleRequiredError. + /// + /// In en, this message translates to: + /// **'Title is required.'** + String get itemCreatePageTitleRequiredError; + + /// No description provided for @itemCreatePageAddImagesButton. + /// + /// In en, this message translates to: + /// **'Add Images'** + String get itemCreatePageAddImagesButton; + + /// No description provided for @itemCreatePageSaveChangesButton. + /// + /// In en, this message translates to: + /// **'Save Changes'** + String get itemCreatePageSaveChangesButton; + + /// No description provided for @itemCreatePageSavingChangesButton. + /// + /// In en, this message translates to: + /// **'Saving...'** + String get itemCreatePageSavingChangesButton; + + /// No description provided for @itemCreatePageDeleteItemButton. + /// + /// In en, this message translates to: + /// **'Delete Item'** + String get itemCreatePageDeleteItemButton; + + /// No description provided for @itemCreatePageDeleteConfirmationTitle. + /// + /// In en, this message translates to: + /// **'Delete Item'** + String get itemCreatePageDeleteConfirmationTitle; + + /// No description provided for @itemCreatePageDeleteConfirmationMessage. + /// + /// In en, this message translates to: + /// **'Are you sure you want to delete this item forever?'** + String get itemCreatePageDeleteConfirmationMessage; + + /// No description provided for @itemCreatePageDeleteConfirmationConfirm. + /// + /// In en, this message translates to: + /// **'Delete'** + String get itemCreatePageDeleteConfirmationConfirm; + + /// No description provided for @itemCreatePageDeleteConfirmationCancel. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get itemCreatePageDeleteConfirmationCancel; + + /// No description provided for @itemCreatePageGenericError. + /// + /// In en, this message translates to: + /// **'An error occurred.'** + String get itemCreatePageGenericError; + + /// No description provided for @itemCreatePageItemDeletedSuccess. + /// + /// In en, this message translates to: + /// **'Item deleted successfully.'** + String get itemCreatePageItemDeletedSuccess; + + /// No description provided for @itemCreatePageItemDeleteError. + /// + /// In en, this message translates to: + /// **'Failed to delete item.'** + String get itemCreatePageItemDeleteError; +} + +class _FlutterCatalogLocalizationsDelegate + extends LocalizationsDelegate { + const _FlutterCatalogLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture( + lookupFlutterCatalogLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['en'].contains(locale.languageCode); + + @override + bool shouldReload(_FlutterCatalogLocalizationsDelegate old) => false; +} + +FlutterCatalogLocalizations lookupFlutterCatalogLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'en': + return FlutterCatalogLocalizationsEn(); + } + + throw FlutterError( + 'FlutterCatalogLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.'); +} diff --git a/packages/flutter_catalog/lib/l10n/app_localizations_en.dart b/packages/flutter_catalog/lib/l10n/app_localizations_en.dart new file mode 100644 index 0000000..bef5f03 --- /dev/null +++ b/packages/flutter_catalog/lib/l10n/app_localizations_en.dart @@ -0,0 +1,106 @@ +import 'app_localizations.dart'; + +/// The translations for English (`en`). +class FlutterCatalogLocalizationsEn extends FlutterCatalogLocalizations { + FlutterCatalogLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get overviewTitle => 'Toys near you'; + + @override + String get filtersTitle => 'Filters'; + + @override + String get searchHint => 'Find toys...'; + + @override + String get filterButton => 'Filters'; + + @override + String get noItemsFound => 'No items found.'; + + @override + String get itemLoadingError => 'Failed to load items.'; + + @override + String get itemCreatePageMandatorySection => 'Mandatory'; + + @override + String get detailDescriptionTitle => 'Description'; + + @override + String get applyFiltersButton => 'Apply filters'; + + @override + String get sendMessageButton => 'Send message'; + + @override + String get characteristicsTitle => 'Characteristics'; + + @override + String get distanceTitle => 'Distance'; + + @override + String postedSince(Object date) { + return 'Posted since $date'; + } + + @override + String get editItemButton => 'Edit'; + + @override + String get contactUserDisabledMessage => + 'You cannot contact the author of this item.'; + + @override + String get itemCreatePageTitle => 'Create Item'; + + @override + String get priceFree => 'Free'; + + @override + String get itemEditPageTitle => 'Edit Item'; + + @override + String get itemCreatePageTitleHint => 'Title'; + + @override + String get itemCreatePageDescriptionHint => 'Description'; + + @override + String get itemCreatePageTitleRequiredError => 'Title is required.'; + + @override + String get itemCreatePageAddImagesButton => 'Add Images'; + + @override + String get itemCreatePageSaveChangesButton => 'Save Changes'; + + @override + String get itemCreatePageSavingChangesButton => 'Saving...'; + + @override + String get itemCreatePageDeleteItemButton => 'Delete Item'; + + @override + String get itemCreatePageDeleteConfirmationTitle => 'Delete Item'; + + @override + String get itemCreatePageDeleteConfirmationMessage => + 'Are you sure you want to delete this item forever?'; + + @override + String get itemCreatePageDeleteConfirmationConfirm => 'Delete'; + + @override + String get itemCreatePageDeleteConfirmationCancel => 'Cancel'; + + @override + String get itemCreatePageGenericError => 'An error occurred.'; + + @override + String get itemCreatePageItemDeletedSuccess => 'Item deleted successfully.'; + + @override + String get itemCreatePageItemDeleteError => 'Failed to delete item.'; +} diff --git a/packages/flutter_catalog/lib/src/config/catalog_builders.dart b/packages/flutter_catalog/lib/src/config/catalog_builders.dart new file mode 100644 index 0000000..4d0a1ad --- /dev/null +++ b/packages/flutter_catalog/lib/src/config/catalog_builders.dart @@ -0,0 +1,114 @@ +import "package:flutter/material.dart"; +import "package:flutter_catalog/src/config/screen_types.dart"; +import "package:flutter_catalog_interface/flutter_catalog_interface.dart"; + +/// A class that holds all the custom UI builders for the catalog user story. +class CatalogBuilders { + /// Constructs a [CatalogBuilders]. + const CatalogBuilders({ + this.baseScreenBuilder, + this.itemCardBuilder, + this.loadingIndicatorBuilder, + this.errorPlaceholderBuilder, + this.noItemsPlaceholderBuilder, + this.filterSectionBuilder, + this.detailPageItemBuilder, + this.primaryButtonBuilder = _defaultPrimaryButtonBuilder, + }); + + /// A builder for the main screen layout. + /// + /// This allows the consuming app to wrap the user story's screens with its + /// own layout, such as adding a [BottomNavigationBar]. The package provides + /// the [appBar] and [body] to be placed within the custom layout. + final BaseScreenBuilder? baseScreenBuilder; + + /// A builder for the item card displayed in the overview grid. + /// + /// If not provided, a default [CatalogGridItem] will be used. + final Widget Function( + BuildContext context, + CatalogItem item, + VoidCallback onTap, + )? itemCardBuilder; + + /// A builder for the loading indicator shown while fetching items. + final WidgetBuilder? loadingIndicatorBuilder; + + /// A builder for the placeholder widget shown when an error occurs. + final Widget Function(BuildContext context, Object? error)? + errorPlaceholderBuilder; + + /// A builder for the placeholder widget shown when no items are found. + final WidgetBuilder? noItemsPlaceholderBuilder; + + /// A builder for a single filter section on the filter screen. + /// + /// - [title]: The name of the filter (e.g., "Condition"). + /// - [child]: The widget that contains the filter's interactive controls + /// (e.g., checkboxes, sliders). + final Widget Function(BuildContext context, String title, Widget child)? + filterSectionBuilder; + + /// A builder for the detail page item. + /// This allows customization of how each item is displayed on the detail + /// page. This only adds more content to the detail page, it does not + /// replace the default content. + final DetailPageItemBuilder? detailPageItemBuilder; + + /// A builder for primary action buttons, like the 'Save' button. + /// + /// If not provided, a default [ElevatedButton] will be used. + final PrimaryButtonBuilder primaryButtonBuilder; + + /// The default builder for the primary button. + static Widget _defaultPrimaryButtonBuilder( + BuildContext context, { + required VoidCallback onPressed, + // onDisabledPressed is ignored by the default implementation + // as a standard ElevatedButton is not clickable when disabled. + required VoidCallback onDisabledPressed, + required bool isDisabled, + required Widget child, + }) => + ElevatedButton( + onPressed: isDisabled ? null : onPressed, + child: child, + ); +} + +/// The base screen builder signature. +/// +/// - [context]: The build context. +/// - [screenType]: The type of screen being built. +/// - [appBar]: The pre-built [AppBar] for the screen. +/// - [title]: The recommended title for the screen. +/// - [body]: The main content widget for the screen. +typedef BaseScreenBuilder = Widget Function( + BuildContext context, + ScreenType screenType, + PreferredSizeWidget appBar, + String? title, + Widget body, +); + +/// A builder for a primary action button. +/// +/// - [onPressed]: The callback for when the button is pressed and enabled. +/// - [onDisabledPressed]: The callback for when the button is pressed but +/// disabled. +/// - [isDisabled]: Whether the button should be in a disabled state. +/// - [child]: The widget to display inside the button. +typedef PrimaryButtonBuilder = Widget Function( + BuildContext context, { + required VoidCallback onPressed, + required VoidCallback onDisabledPressed, + required bool isDisabled, + required Widget child, +}); + +/// A builder for the detail page item. +typedef DetailPageItemBuilder = Widget Function( + BuildContext context, + CatalogItem item, +); diff --git a/packages/flutter_catalog/lib/src/config/catalog_filter_options.dart b/packages/flutter_catalog/lib/src/config/catalog_filter_options.dart new file mode 100644 index 0000000..11f22f4 --- /dev/null +++ b/packages/flutter_catalog/lib/src/config/catalog_filter_options.dart @@ -0,0 +1,67 @@ +import "package:dart_feed_utilities/dart_feed_utilities.dart"; + +/// A function that takes a localization key and returns a translated string. +typedef Translator = String Function(String key); + +/// A configuration class for managing filter-related repositories and options. +class FilterOptions { + /// Constructs a [FilterOptions]. + const FilterOptions({ + this.showFiltersInOverview = true, + this.showSearchInOverview = true, + this.filterRepository, + this.filterValueRepository, + this.filterDataSourceRepository, + this.namespace, + this.translator, + }); + + /// Whether to show the filters in the overview screen. + /// If disabled the filter section on the overview screen will not be + /// displayed + final bool showFiltersInOverview; + + /// Whether to show the search bar in the overview screen's AppBar. + /// Defaults to true. + final bool showSearchInOverview; + + /// The repository for managing filter data. + final FilterRepository? filterRepository; + + /// The repository for managing filter values. + final FilterValueRepository? filterValueRepository; + + /// The repository for managing filter data sources. + final FilterDataSourceRepository? filterDataSourceRepository; + + /// An optional namespace to scope the filters. + final String? namespace; + + /// A function that provides translations for dynamic filter names. + /// If not provided, the keys themselves will be displayed as a fallback. + final Translator? translator; + + /// Creates a copy of this object with the given fields replaced + /// with the new values. + FilterOptions copyWith({ + bool? showFiltersInOverview, + bool? showSearchInOverview, + FilterRepository? filterRepository, + FilterValueRepository? filterValueRepository, + FilterDataSourceRepository? filterDataSourceRepository, + String? namespace, + Translator? translator, + }) => + FilterOptions( + showFiltersInOverview: + showFiltersInOverview ?? this.showFiltersInOverview, + showSearchInOverview: showSearchInOverview ?? this.showSearchInOverview, + filterRepository: filterRepository ?? this.filterRepository, + filterValueRepository: + filterValueRepository ?? this.filterValueRepository, + filterDataSourceRepository: + filterDataSourceRepository ?? this.filterDataSourceRepository, + namespace: namespace ?? this.namespace, + translator: translator ?? this.translator, + ); +} diff --git a/packages/flutter_catalog/lib/src/config/catalog_options.dart b/packages/flutter_catalog/lib/src/config/catalog_options.dart new file mode 100644 index 0000000..e74faf1 --- /dev/null +++ b/packages/flutter_catalog/lib/src/config/catalog_options.dart @@ -0,0 +1,83 @@ +import "package:flutter/material.dart"; +import "package:flutter_catalog/src/config/catalog_builders.dart"; +import "package:flutter_catalog/src/config/catalog_filter_options.dart"; +import "package:flutter_catalog/src/config/catalog_theme.dart"; +import "package:flutter_catalog/src/config/catalog_translations.dart"; +import "package:flutter_catalog/src/repositories/memory_catalog_repository.dart"; +import "package:flutter_catalog/src/repositories/memory_catalog_user_repository.dart"; +import "package:flutter_catalog_interface/flutter_catalog_interface.dart"; + +/// A comprehensive configuration class for the Catalog User Story. +/// +/// Use this class to customize all aspects of the feature, from data +/// repositories to UI builders and translations. +class CatalogOptions { + /// Constructs a [CatalogOptions]. + CatalogOptions({ + CatalogRepository? catalogRepository, + CatalogUserRepository? catalogUserRepository, + this.builders = const CatalogBuilders(), + this.theme = const CatalogTheme(), + this.translations = const CatalogTranslations.empty(), + this.onPressContactUser, + this.filterOptions, + this.onNoItems, + }) : catalogRepository = catalogRepository ?? MemoryCatalogRepository(), + catalogUserRepository = + catalogUserRepository ?? MemoryCatalogUserRepository(); + + /// The repository for fetching and managing catalog item data. + /// + /// Defaults to an in-memory repository for easy setup and testing. + final CatalogRepository catalogRepository; + + /// The repository for fetching and managing user data. + /// + /// Defaults to an in-memory repository for easy setup and testing. + final CatalogUserRepository catalogUserRepository; + + /// A collection of builders to customize the UI components. + final CatalogBuilders builders; + + /// All theme and style configurations for the UI. + final CatalogTheme theme; + + /// The translations that can be updated by the consuming app. + /// The userstory also has a default set of translations used with + /// flutter_localizations. + final CatalogTranslations translations; + + /// An optional callback that is triggered when an item fetch results + /// in an empty list. + final VoidCallback? onNoItems; + + /// A callback to execute when the user wants to contact the author. + final void Function(CatalogUser user)? onPressContactUser; + + /// An optional filter configuration for managing item filtering. + final FilterOptions? filterOptions; + + /// Creates a copy of this object with the given fields replaced + /// with the new values. + CatalogOptions copyWith({ + CatalogRepository? catalogRepository, + CatalogUserRepository? catalogUserRepository, + CatalogBuilders? builders, + CatalogTheme? theme, + CatalogTranslations? translations, + VoidCallback? onNoItems, + FilterOptions? filterOptions, + void Function(CatalogUser user)? onPressContactUser, + }) => + CatalogOptions( + catalogRepository: catalogRepository ?? this.catalogRepository, + catalogUserRepository: + catalogUserRepository ?? this.catalogUserRepository, + builders: builders ?? this.builders, + theme: theme ?? this.theme, + translations: translations ?? this.translations, + onNoItems: onNoItems ?? this.onNoItems, + filterOptions: filterOptions ?? this.filterOptions, + onPressContactUser: onPressContactUser ?? this.onPressContactUser, + ); +} diff --git a/packages/flutter_catalog/lib/src/config/catalog_theme.dart b/packages/flutter_catalog/lib/src/config/catalog_theme.dart new file mode 100644 index 0000000..88af3c1 --- /dev/null +++ b/packages/flutter_catalog/lib/src/config/catalog_theme.dart @@ -0,0 +1,21 @@ +import "package:flutter/material.dart"; + +/// A class that holds all the theme and style configurations for the +/// catalog user story. +class CatalogTheme { + /// Constructs a [CatalogTheme]. + const CatalogTheme({ + this.filterBarBackgroundColor, + this.authorSectionBackgroundColor, + }); + + /// The background color of the persistent filter bar on the overview screen. + /// + /// If not provided, it defaults to + /// `Theme.of(context).colorScheme.secondaryContainer`. + final Color? filterBarBackgroundColor; + + /// The background color of the author section on the detail page. + /// If not provided, it defaults to a semi-transparent primary color. + final Color? authorSectionBackgroundColor; +} diff --git a/packages/flutter_catalog/lib/src/config/catalog_translations.dart b/packages/flutter_catalog/lib/src/config/catalog_translations.dart new file mode 100644 index 0000000..fe844b7 --- /dev/null +++ b/packages/flutter_catalog/lib/src/config/catalog_translations.dart @@ -0,0 +1,162 @@ +/// A class that holds all the overridable translations for the catalog user +/// story. +class CatalogTranslations { + /// Creates an instance of [CatalogTranslations]. + const CatalogTranslations({ + this.overviewTitle, + this.searchHint, + this.itemCreatePageTitle, + this.itemEditPageTitle, + this.itemCreatePageTitleHint, + this.itemCreatePageDescriptionHint, + this.itemCreatePageTitleRequiredError, + this.itemCreatePageAddImagesButton, + this.itemCreatePageSaveChangesButton, + this.itemCreatePageSavingChangesButton, + this.itemCreatePageDeleteItemButton, + this.itemCreatePageDeleteConfirmationTitle, + this.itemCreatePageDeleteConfirmationMessage, + this.itemCreatePageDeleteConfirmationConfirm, + this.itemCreatePageDeleteConfirmationCancel, + this.itemCreatePageGenericError, + this.itemCreatePageItemDeletedSuccess, + this.itemCreatePageItemDeleteError, + }); + + /// Creates an empty instance of [CatalogTranslations] with all fields set to + /// `null`. This is useful for providing default values in the consuming app. + const CatalogTranslations.empty({ + this.overviewTitle, + this.searchHint, + this.itemCreatePageTitle, + this.itemEditPageTitle, + this.itemCreatePageTitleHint, + this.itemCreatePageDescriptionHint, + this.itemCreatePageTitleRequiredError, + this.itemCreatePageAddImagesButton, + this.itemCreatePageSaveChangesButton, + this.itemCreatePageSavingChangesButton, + this.itemCreatePageDeleteItemButton, + this.itemCreatePageDeleteConfirmationTitle, + this.itemCreatePageDeleteConfirmationMessage, + this.itemCreatePageDeleteConfirmationConfirm, + this.itemCreatePageDeleteConfirmationCancel, + this.itemCreatePageGenericError, + this.itemCreatePageItemDeletedSuccess, + this.itemCreatePageItemDeleteError, + }); + + /// The title for the catalog overview screen. + final String? overviewTitle; + + /// The hint text for the search field in the catalog overview screen. + final String? searchHint; + + /// The title for the item creation page. + final String? itemCreatePageTitle; + + /// The title for the item editing page. + final String? itemEditPageTitle; + + /// The hint text for the title input field on the create/edit page. + final String? itemCreatePageTitleHint; + + /// The hint text for the description input field on the create/edit page. + final String? itemCreatePageDescriptionHint; + + /// The error message shown when the title is empty. + final String? itemCreatePageTitleRequiredError; + + /// The text for the button to add images on the create/edit page. + final String? itemCreatePageAddImagesButton; + + /// The text for the save button on the create/edit page. + final String? itemCreatePageSaveChangesButton; + + /// The text for the save button when it is in a loading state. + final String? itemCreatePageSavingChangesButton; + + /// The text for the delete button on the edit page. + final String? itemCreatePageDeleteItemButton; + + /// The title for the delete confirmation dialog. + final String? itemCreatePageDeleteConfirmationTitle; + + /// The message for the delete confirmation dialog. + final String? itemCreatePageDeleteConfirmationMessage; + + /// The text for the confirm button in the delete confirmation dialog. + final String? itemCreatePageDeleteConfirmationConfirm; + + /// The text for the cancel button in the delete confirmation dialog. + final String? itemCreatePageDeleteConfirmationCancel; + + /// The generic error message for snackbars. + final String? itemCreatePageGenericError; + + /// The success message when an item is deleted. + final String? itemCreatePageItemDeletedSuccess; + + /// The error message when an item fails to delete. + final String? itemCreatePageItemDeleteError; + + /// Creates a copy of this object with the given fields replaced. + CatalogTranslations copyWith({ + String? overviewTitle, + String? searchHint, + String? itemCreatePageTitle, + String? itemEditPageTitle, + String? itemCreatePageTitleHint, + String? itemCreatePageDescriptionHint, + String? itemCreatePageTitleRequiredError, + String? itemCreatePageSaveChangesButton, + String? itemCreatePageSavingChangesButton, + String? itemCreatePageDeleteItemButton, + String? itemCreatePageDeleteConfirmationTitle, + String? itemCreatePageDeleteConfirmationMessage, + String? itemCreatePageDeleteConfirmationConfirm, + String? itemCreatePageDeleteConfirmationCancel, + String? itemCreatePageGenericError, + String? itemCreatePageAddImagesButton, + String? itemCreatePageItemDeletedSuccess, + String? itemCreatePageItemDeleteError, + }) => + CatalogTranslations( + overviewTitle: overviewTitle ?? this.overviewTitle, + searchHint: searchHint ?? this.searchHint, + itemCreatePageTitle: itemCreatePageTitle ?? this.itemCreatePageTitle, + itemEditPageTitle: itemEditPageTitle ?? this.itemEditPageTitle, + itemCreatePageTitleHint: + itemCreatePageTitleHint ?? this.itemCreatePageTitleHint, + itemCreatePageDescriptionHint: + itemCreatePageDescriptionHint ?? this.itemCreatePageDescriptionHint, + itemCreatePageTitleRequiredError: itemCreatePageTitleRequiredError ?? + this.itemCreatePageTitleRequiredError, + itemCreatePageSaveChangesButton: itemCreatePageSaveChangesButton ?? + this.itemCreatePageSaveChangesButton, + itemCreatePageSavingChangesButton: itemCreatePageSavingChangesButton ?? + this.itemCreatePageSavingChangesButton, + itemCreatePageDeleteItemButton: itemCreatePageDeleteItemButton ?? + this.itemCreatePageDeleteItemButton, + itemCreatePageDeleteConfirmationTitle: + itemCreatePageDeleteConfirmationTitle ?? + this.itemCreatePageDeleteConfirmationTitle, + itemCreatePageDeleteConfirmationMessage: + itemCreatePageDeleteConfirmationMessage ?? + this.itemCreatePageDeleteConfirmationMessage, + itemCreatePageDeleteConfirmationConfirm: + itemCreatePageDeleteConfirmationConfirm ?? + this.itemCreatePageDeleteConfirmationConfirm, + itemCreatePageDeleteConfirmationCancel: + itemCreatePageDeleteConfirmationCancel ?? + this.itemCreatePageDeleteConfirmationCancel, + itemCreatePageGenericError: + itemCreatePageGenericError ?? this.itemCreatePageGenericError, + itemCreatePageItemDeletedSuccess: itemCreatePageItemDeletedSuccess ?? + this.itemCreatePageItemDeletedSuccess, + itemCreatePageItemDeleteError: + itemCreatePageItemDeleteError ?? this.itemCreatePageItemDeleteError, + itemCreatePageAddImagesButton: + itemCreatePageAddImagesButton ?? this.itemCreatePageAddImagesButton, + ); +} diff --git a/packages/flutter_catalog/lib/src/config/screen_types.dart b/packages/flutter_catalog/lib/src/config/screen_types.dart new file mode 100644 index 0000000..11b6df9 --- /dev/null +++ b/packages/flutter_catalog/lib/src/config/screen_types.dart @@ -0,0 +1,19 @@ +/// Defines the different screens within the Catalog user story. +/// +/// Used by builders to provide screen-specific layouts. +enum ScreenType { + /// The main screen displaying the grid of items. + catalogOverview, + + /// The screen displaying the details of a single item. + catalogDetail, + + /// The screen for selecting and applying filters. + catalogFilter, + + /// The screen for selecting sub-filters. + catalogSubFilter, + + /// The screen for modifying or updating a catalog item. + catalogModify, +} diff --git a/packages/flutter_catalog/lib/src/flutter_catalog_userstory.dart b/packages/flutter_catalog/lib/src/flutter_catalog_userstory.dart new file mode 100644 index 0000000..ecafd2f --- /dev/null +++ b/packages/flutter_catalog/lib/src/flutter_catalog_userstory.dart @@ -0,0 +1,162 @@ +// SPDX-FileCopyrightText: 2025 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause +import "package:dart_feed_utilities/filters.dart"; +import "package:flutter/material.dart"; +import "package:flutter_catalog/src/config/catalog_options.dart"; +import "package:flutter_catalog/src/repositories/memory_filter_repository.dart"; +import "package:flutter_catalog/src/routes.dart"; +import "package:flutter_catalog/src/services/catalog_service.dart"; +import "package:flutter_catalog/src/services/pop_handler.dart"; +import "package:flutter_catalog/src/utils/scope.dart"; +import "package:flutter_catalog_interface/flutter_catalog_interface.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; + +/// A self-contained user story for Browse a catalog of items. +/// +/// Starts with the overview screen. +class FlutterCatalogUserstory extends _BaseCatalogNavigatorUserstory { + /// Constructs a [FlutterCatalogUserstory]. + const FlutterCatalogUserstory({ + required super.userId, + required super.options, + super.userLocation, + super.onExit, + super.key, + }); + + @override + MaterialPageRoute buildInitialRoute( + BuildContext context, + ) => + catalogOverviewRoute( + onExit: onExit, + ); +} + +/// A self-contained user story that starts directly on an item's detail page. +class FlutterCatalogDetailUserstory extends _BaseCatalogNavigatorUserstory { + /// Constructs a [FlutterCatalogDetailUserstory]. + const FlutterCatalogDetailUserstory({ + required super.userId, + required super.options, + required this.item, + super.userLocation, + super.onExit, + super.key, + }); + + /// The catalog item to display initially. + final CatalogItem item; + + @override + MaterialPageRoute buildInitialRoute( + BuildContext context, + ) => + catalogDetailRoute( + item: item, + ); +} + +/// A self-contained user story for creating or editing a catalog item. +class FlutterCatalogModifyUserstory extends _BaseCatalogNavigatorUserstory { + /// Constructs a [FlutterCatalogModifyUserstory]. + const FlutterCatalogModifyUserstory({ + required super.userId, + required super.options, + super.userLocation, + this.initialItem, + super.onExit, + super.key, + }); + + /// The initial catalog item to edit, or `null` for creating a new item. + final CatalogItem? initialItem; + + @override + MaterialPageRoute buildInitialRoute(BuildContext context) => + catalogModifyRoute( + initialItem: initialItem, + onExit: onExit ?? () => Navigator.of(context).pop(), + ); +} + +/// Base hook widget for catalog navigator user stories. +abstract class _BaseCatalogNavigatorUserstory extends HookWidget { + /// Constructs a [_BaseCatalogNavigatorUserstory]. + const _BaseCatalogNavigatorUserstory({ + required this.userId, + required this.options, + this.userLocation, + this.onExit, + super.key, + }); + + /// The user ID of the person starting the catalog user story. + final String userId; + + /// The user's current location, if available. + /// This is used to fetch location-based catalog items. + /// If `null`, location-based features are disabled. + final LatLng? userLocation; + + /// The catalog user story configuration. + final CatalogOptions options; + + /// Callback for when the user wants to navigate out of the user story. + final VoidCallback? onExit; + + /// Implemented by subclasses to provide the initial route of the user story. + MaterialPageRoute buildInitialRoute( + BuildContext context, + ); + + @override + Widget build(BuildContext context) { + var catalogService = useMemoized( + () => CatalogService( + repository: options.catalogRepository, + userRepository: options.catalogUserRepository, + userId: userId, + userLocation: userLocation, + ), + [ + options.catalogRepository, + options.catalogUserRepository, + userId, + userLocation, + ], + ); + + var filterService = useMemoized( + () => FilterService( + filterRepository: options.filterOptions?.filterRepository ?? + CatalogMemoryFilterRepository(), + filterValueRepository: options.filterOptions?.filterValueRepository, + filterDataSourceRepository: + options.filterOptions?.filterDataSourceRepository, + namespace: options.filterOptions?.namespace, + ), + [options.filterOptions], + ); + var popHandler = useMemoized(PopHandler.new, []); + + return CatalogScope( + userId: userId, + options: options, + catalogService: catalogService, + filterService: filterService, + popHandler: popHandler, + child: NavigatorPopHandler( + onPop: popHandler.handlePop, + child: Navigator( + onGenerateInitialRoutes: (_, __) => [ + buildInitialRoute( + context, + ), + ], + ), + ), + ); + } +} diff --git a/packages/flutter_catalog/lib/src/models/item_view_model.dart b/packages/flutter_catalog/lib/src/models/item_view_model.dart new file mode 100644 index 0000000..9746c31 --- /dev/null +++ b/packages/flutter_catalog/lib/src/models/item_view_model.dart @@ -0,0 +1,70 @@ +import "package:flutter_catalog/l10n/app_localizations.dart"; +import "package:flutter_catalog_interface/flutter_catalog_interface.dart"; + +/// A data class to manage the state of the item modification form. +class ItemModificationData { + /// + const ItemModificationData({ + this.title, + this.description, + this.newImages = const [], + this.existingImageUrls = const [], + this.customFieldValues = const {}, + }); + + /// Creates an [ItemModificationData] from a [CatalogItem]. + factory ItemModificationData.fromItem(CatalogItem item) => + ItemModificationData( + title: item.title, + description: item.description, + existingImageUrls: item.imageUrls, + customFieldValues: item.customFields, + ); + + /// + final String? title; + + /// + final String? description; + + /// + final List newImages; + + /// + final List existingImageUrls; + + /// Custom field values for the item. + final Map customFieldValues; + + /// + ItemModificationData copyWith({ + String? title, + String? description, + List? newImages, + List? existingImageUrls, + Map? customFieldValues, + }) => + ItemModificationData( + title: title ?? this.title, + description: description ?? this.description, + newImages: newImages ?? this.newImages, + existingImageUrls: existingImageUrls ?? this.existingImageUrls, + customFieldValues: customFieldValues ?? this.customFieldValues, + ); + + /// Validates the form data. Returns an error message if invalid. + String? validate(FlutterCatalogLocalizations localizations) { + if (title == null || title!.isEmpty) { + return localizations.itemCreatePageTitleRequiredError; + } + return null; + } + + /// Converts the form data to a JSON map suitable for the repository. + Map toJson({required List allImageUrls}) => { + "title": title, + "description": description, + "image_urls": allImageUrls, + ...customFieldValues, + }; +} diff --git a/packages/flutter_catalog/lib/src/repositories/memory_catalog_repository.dart b/packages/flutter_catalog/lib/src/repositories/memory_catalog_repository.dart new file mode 100644 index 0000000..dff3301 --- /dev/null +++ b/packages/flutter_catalog/lib/src/repositories/memory_catalog_repository.dart @@ -0,0 +1,190 @@ +import "dart:math"; +import "package:flutter_catalog_interface/flutter_catalog_interface.dart"; + +/// An in-memory implementation of the [CatalogRepository]. +/// +/// Used for demonstration and testing purposes. It simulates data fetching +/// and basic operations for catalog items, including per-user favorites +/// and distance calculation based on a provided user location. +class MemoryCatalogRepository implements CatalogRepository { + final List _baseItems = [ + CatalogItem( + id: "1", + title: "Teddybeer", + description: "A soft and cuddly teddy bear for all ages.", + price: 3.00, + authorId: "author_vic", + location: const LatLng(latitude: 51.980, longitude: 5.910), + imageUrls: const ["https://picsum.photos/seed/teddy/200/300"], + postedAt: DateTime.now().subtract(const Duration(days: 5)), + ), + CatalogItem( + id: "2", + title: "Treintje", + description: + "Product description. Lorem ipsum dolor sit amet, consectetur " + "adipiscing elit, sed do eiusmod tempor incididunt ut " + "labore et dolore magna.", + price: 0.00, + authorId: "author_mar", + location: const LatLng(latitude: 51.985, longitude: 5.914), + imageUrls: const ["https://picsum.photos/seed/train/200/300"], + postedAt: DateTime(2025, 6, 2), + ), + CatalogItem( + id: "3", + title: "Legoblokkenset", + description: "Large set of colorful Lego blocks for creative building.", + price: 0.00, + authorId: "author_jan", + location: const LatLng(latitude: 51.975, longitude: 5.920), + imageUrls: const ["https://picsum.photos/seed/lego/200/300"], + postedAt: DateTime.now().subtract(const Duration(days: 10)), + ), + ]; + + final Map> _userFavorites = {}; + + @override + Future> fetchCatalogItems({ + required String userId, + LatLng? userLocation, + Map? filters, + int? limit, + int? offset, + }) async { + await Future.delayed(const Duration(milliseconds: 300)); + + var itemsToProcess = List.from(_baseItems); + + if (filters != null && filters.containsKey("q")) { + var query = filters["q"].toString().toLowerCase(); + if (query.isNotEmpty) { + itemsToProcess = itemsToProcess + .where((item) => item.title.toLowerCase().contains(query)) + .toList(); + } + } + + itemsToProcess = itemsToProcess.map((item) { + var isItemFavorited = _userFavorites[userId]?.contains(item.id) ?? false; + double? calculatedDistanceKm; + + if (userLocation != null) { + calculatedDistanceKm = _calculateDistance(userLocation, item.location); + } + + return item.copyWith( + isFavorited: isItemFavorited, + distanceKM: calculatedDistanceKm, + ); + }).toList(); + + var startIndex = offset ?? 0; + var endIndex = (limit != null) + ? min(startIndex + limit, itemsToProcess.length) + : itemsToProcess.length; + + if (startIndex >= itemsToProcess.length) { + return []; + } + + return itemsToProcess.sublist(startIndex, endIndex); + } + + @override + Future fetchCatalogItemById(String id, String userId) async { + await Future.delayed(const Duration(milliseconds: 100)); + return _baseItems.where((item) => item.id == id).firstOrNull; + } + + @override + Future toggleFavorite(String itemId, String userId) async { + await Future.delayed(const Duration(milliseconds: 200)); + + if (!_userFavorites.containsKey(userId)) { + _userFavorites[userId] = {}; + } + + var isCurrentlyFavorite = _userFavorites[userId]!.contains(itemId); + + if (isCurrentlyFavorite) { + _userFavorites[userId]!.remove(itemId); + } else { + _userFavorites[userId]!.add(itemId); + } + } + + @override + Future uploadImage(XFile imageFile) async { + await Future.delayed(const Duration(milliseconds: 500)); + return "https://example.com/images/${imageFile.name}"; + } + + @override + Future createCatalogItem(Map item) async { + await Future.delayed(const Duration(milliseconds: 400)); + + var newItem = CatalogItem( + id: Random().nextInt(99999).toString(), + title: item["title"] ?? "No Title", + description: item["description"] ?? "", + imageUrls: List.from(item["image_urls"] as List? ?? []), + price: (item["price"] as num?)?.toDouble(), + location: const LatLng(latitude: 51.98, longitude: 5.91), + postedAt: DateTime.now(), + authorId: item["authorId"], + ); + + _baseItems.insert(0, newItem); + } + + @override + Future updateCatalogItem( + String itemId, + Map item, + ) async { + await Future.delayed(const Duration(milliseconds: 400)); + var index = _baseItems.indexWhere((i) => i.id == itemId); + if (index != -1) { + var existingItem = _baseItems[index]; + // Create a new item by applying the updates to the existing one. + var updatedItem = existingItem.copyWith( + title: item["title"] ?? existingItem.title, + description: item["description"] ?? existingItem.description, + price: item.containsKey("price") + ? (item["price"] as num?)?.toDouble() + : existingItem.price, + imageUrls: item.containsKey("image_urls") + ? List.from(item["image_urls"]) + : existingItem.imageUrls, + ); + _baseItems[index] = updatedItem; + } + } + + @override + Future deleteCatalogItem(String itemId) async { + await Future.delayed(const Duration(milliseconds: 300)); + _baseItems.removeWhere((item) => item.id == itemId); + } + + double _calculateDistance(LatLng point1, LatLng point2) { + const earthRadiusKm = 6371; + var lat1Rad = _degreesToRadians(point1.latitude); + var lon1Rad = _degreesToRadians(point1.longitude); + var lat2Rad = _degreesToRadians(point2.latitude); + var lon2Rad = _degreesToRadians(point2.longitude); + + var dLat = lat2Rad - lat1Rad; + var dLon = lon2Rad - lon1Rad; + + var a = sin(dLat / 2) * sin(dLat / 2) + + cos(lat1Rad) * cos(lat2Rad) * sin(dLon / 2) * sin(dLon / 2); + var c = 2 * atan2(sqrt(a), sqrt(1 - a)); + + return earthRadiusKm * c; + } + + double _degreesToRadians(double degrees) => degrees * pi / 180; +} diff --git a/packages/flutter_catalog/lib/src/repositories/memory_catalog_user_repository.dart b/packages/flutter_catalog/lib/src/repositories/memory_catalog_user_repository.dart new file mode 100644 index 0000000..3a6cc05 --- /dev/null +++ b/packages/flutter_catalog/lib/src/repositories/memory_catalog_user_repository.dart @@ -0,0 +1,42 @@ +import "package:flutter_catalog_interface/flutter_catalog_interface.dart"; + +/// An in-memory implementation of the [CatalogUserRepository]. +/// +/// Used for demonstration and testing purposes. It simulates fetching +/// user data from a predefined list. +class MemoryCatalogUserRepository implements CatalogUserRepository { + final List _users = const [ + CatalogUser( + id: "author_vic", + name: "Victor", + avatarUrl: "https://i.pravatar.cc/150?u=author_vic", + ), + CatalogUser( + id: "author_mar", + name: "Martijn", + avatarUrl: "https://i.pravatar.cc/150?u=author_mar", + ), + CatalogUser( + id: "author_jan", + name: "Janneke", + avatarUrl: "https://i.pravatar.cc/150?u=author_jan", + ), + CatalogUser( + id: "author_eli", + name: "Elisa", + avatarUrl: "https://i.pravatar.cc/150?u=author_eli", + ), + ]; + + @override + Future getUser(String userId) async { + await Future.delayed(const Duration(milliseconds: 150)); + return _users.where((user) => user.id == userId).firstOrNull; + } + + @override + Future> getUsers(List userIds) async { + await Future.delayed(const Duration(milliseconds: 150)); + return _users.where((user) => userIds.contains(user.id)).toList(); + } +} diff --git a/packages/flutter_catalog/lib/src/repositories/memory_filter_repository.dart b/packages/flutter_catalog/lib/src/repositories/memory_filter_repository.dart new file mode 100644 index 0000000..001b3d4 --- /dev/null +++ b/packages/flutter_catalog/lib/src/repositories/memory_filter_repository.dart @@ -0,0 +1,206 @@ +import "package:dart_feed_utilities/dart_feed_utilities.dart"; + +/// An in-memory implementation of [FilterRepository] that provides a set of +/// filters based on a standard catalog use case. +class CatalogMemoryFilterRepository implements FilterRepository { + final _filters = >{ + "search": { + FilterModel.keys.id: "search", + FilterModel.keys.key: "q", + FilterModel.keys.name: "Search", + FilterModel.keys.imageUrl: "", + FilterModel.keys.type: "text", + FilterModel.keys.metadata: {}, + }, + "condition": { + FilterModel.keys.id: "condition", + FilterModel.keys.key: "condition", + FilterModel.keys.name: "Conditie", + FilterModel.keys.imageUrl: "", + FilterModel.keys.type: DataSourceMultiSelectFilter.filterType, + FilterModel.keys.metadata: { + "datasource": "conditions", + "multiSelect": true, + "isNested": false, + }, + }, + "price_options": { + FilterModel.keys.id: "price_options", + FilterModel.keys.key: "price_options", + FilterModel.keys.name: "Prijs", + FilterModel.keys.imageUrl: "", + FilterModel.keys.type: DataSourceMultiSelectFilter.filterType, + FilterModel.keys.metadata: { + "datasource": "price_options", + "multiSelect": true, + "isNested": false, + }, + }, + "price_range": { + FilterModel.keys.id: "price_range", + FilterModel.keys.key: "price_range", + FilterModel.keys.name: "Prijs (bereik)", + FilterModel.keys.imageUrl: "", + FilterModel.keys.type: MinMaxIntFilter.filterType, + FilterModel.keys.metadata: { + "min": 0, + "max": 100, + "defaultValue": 50, + "isRange": true, + }, + }, + "age": { + FilterModel.keys.id: "age", + FilterModel.keys.key: "age", + FilterModel.keys.name: "Leeftijd", + FilterModel.keys.imageUrl: "", + FilterModel.keys.type: DataSourceMultiSelectFilter.filterType, + FilterModel.keys.metadata: { + "datasource": "age_ranges", + "multiSelect": true, + "isNested": false, + }, + }, + "category": { + FilterModel.keys.id: "category", + FilterModel.keys.key: "category", + FilterModel.keys.name: "Categorie", + FilterModel.keys.imageUrl: "", + FilterModel.keys.type: DataSourceMultiSelectFilter.filterType, + FilterModel.keys.metadata: { + "datasource": "categories", + "multiSelect": true, + "isNested": true, + "isSearchEnabled": true, + }, + }, + "brand": { + FilterModel.keys.id: "brand", + FilterModel.keys.key: "brand", + FilterModel.keys.name: "Merk", + FilterModel.keys.imageUrl: "", + FilterModel.keys.type: DataSourceMultiSelectFilter.filterType, + FilterModel.keys.metadata: { + "datasource": "brands", + "multiSelect": true, + "isNested": false, + "isSearchEnabled": true, + }, + }, + }; + + @override + Future getFilter(String namespace, String filterId) async { + var filter = _filters[filterId]; + if (filter == null) { + throw FilterNotFoundException(); + } + return FilterModel.fromMap(filter); + } + + @override + Future> getFilters(String namespace) async => + _filters.values.map(FilterModel.fromMap).toList(); +} + +/// An in-memory implementation of [FilterDataSourceRepository] that provides +/// data for the filters defined in [CatalogMemoryFilterRepository]. +class MemoryPlayhoodFilterDataSourceRepository + implements FilterDataSourceRepository { + final _data = >{ + "conditions": [ + const LinkedFilterData(key: "new", name: "Nieuw"), + const LinkedFilterData(key: "as_new", name: "Zo goed als nieuw"), + const LinkedFilterData(key: "good", name: "Gebruikt - goede staat"), + const LinkedFilterData( + key: "damaged", + name: "Gebruikt - met schade of incompleet", + ), + ], + "price_options": [ + const LinkedFilterData(key: "free", name: "Gratis op te halen"), + const LinkedFilterData(key: "chat", name: "Prijs in overleg (via chat)"), + const LinkedFilterData(key: "price", name: "Prijs"), + ], + "age_ranges": [ + const LinkedFilterData(key: "0-2", name: "0-2 jaar"), + const LinkedFilterData(key: "2-4", name: "2-4 jaar"), + const LinkedFilterData(key: "4-6", name: "4-6 jaar"), + const LinkedFilterData(key: "6-8", name: "6-8 jaar"), + const LinkedFilterData(key: "8-12", name: "8-12 jaar"), + const LinkedFilterData(key: "12+", name: "12+ jaar"), + ], + "brands": [ + const LinkedFilterData(key: "barbie", name: "Barbie"), + const LinkedFilterData(key: "brio", name: "Brio"), + const LinkedFilterData(key: "chicco", name: "Chicco"), + const LinkedFilterData(key: "duplo", name: "Duplo"), + const LinkedFilterData(key: "fisher-price", name: "Fisher-Price"), + const LinkedFilterData(key: "hot-wheels", name: "Hot Wheels"), + const LinkedFilterData(key: "lego", name: "Lego"), + ], + "categories": [ + // Main Categories + const LinkedFilterData(key: "indoor", name: "Binnenspeelgoed"), + const LinkedFilterData(key: "outdoor", name: "Buitenspeelgoed"), + + // Outdoor Sub-categories + const LinkedFilterData( + key: "outdoor_vehicles", + name: "Voertuigen & Mobiliteit", + parent: "outdoor", + ), + const LinkedFilterData( + key: "outdoor_action", + name: "Sport & Actie", + parent: "outdoor", + ), + const LinkedFilterData( + key: "outdoor_climbing", + name: "Klimmen, Glijden & Springen", + parent: "outdoor", + ), + + // Indoor Sub-categories + const LinkedFilterData( + key: "indoor_baby", + name: "Baby & Peuter", + parent: "indoor", + ), + const LinkedFilterData( + key: "indoor_construction", + name: "Bouw & Constructie", + parent: "indoor", + ), + const LinkedFilterData( + key: "indoor_creative", + name: "Educatief & Creatief", + parent: "indoor", + ), + ], + }; + + @override + Future> getLinkedDataFromSource( + String namespace, + String datasource, + ) async => + _data[datasource] ?? []; + + @override + Future> searchLinkedDataFromSource( + String namespace, + String datasource, + String searchString, + ) async { + var allData = await getLinkedDataFromSource(namespace, datasource); + if (searchString.isEmpty) return allData; + + return allData + .where( + (data) => + data.name.toLowerCase().contains(searchString.toLowerCase()), + ) + .toList(); + } +} diff --git a/packages/flutter_catalog/lib/src/routes.dart b/packages/flutter_catalog/lib/src/routes.dart new file mode 100644 index 0000000..f148296 --- /dev/null +++ b/packages/flutter_catalog/lib/src/routes.dart @@ -0,0 +1,105 @@ +import "package:dart_feed_utilities/dart_feed_utilities.dart"; +import "package:flutter/material.dart"; +import "package:flutter_catalog/src/views/catalog_detail_view.dart"; +import "package:flutter_catalog/src/views/catalog_filter_view.dart"; +import "package:flutter_catalog/src/views/catalog_modify_view.dart"; +import "package:flutter_catalog/src/views/catalog_overview_view.dart"; +import "package:flutter_catalog/src/views/catalog_sub_filter_view.dart"; +import "package:flutter_catalog_interface/flutter_catalog_interface.dart"; + +/// A callback type for navigating to a sub-filter selection screen. +typedef NavigateToSubFilterCallback = Future?> Function( + BuildContext context, + DataSourceMultiSelectFilter filter, + List initialSelection, +); + +/// Returns a [MaterialPageRoute] for the catalog overview screen. +MaterialPageRoute catalogOverviewRoute({ + required VoidCallback? onExit, +}) => + MaterialPageRoute( + builder: (context) => CatalogOverviewView( + onExit: onExit, + onPressItem: (item) async => _routeToScreen( + context, + catalogDetailRoute( + item: item, + ).builder(context), + ), + onPressFilters: () async => _routeToScreen( + context, + catalogFilterRoute().builder(context), + ), + ), + ); + +/// Returns a [MaterialPageRoute] for the catalog item detail screen. +MaterialPageRoute catalogDetailRoute({ + required CatalogItem item, +}) => + MaterialPageRoute( + builder: (context) => CatalogDetailView( + item: item, + onExit: () => Navigator.of(context).pop(), + onEditItem: (itemToEdit) async => _routeToScreen( + context, + catalogModifyRoute( + initialItem: itemToEdit, + onExit: () => Navigator.of(context).pop(), + ).builder(context), + ), + ), + ); + +/// Returns a [MaterialPageRoute] for the create/edit screen. +MaterialPageRoute catalogModifyRoute({ + required VoidCallback onExit, + CatalogItem? initialItem, +}) => + MaterialPageRoute( + builder: (context) => CatalogModifyView( + initialItem: initialItem, + onExit: onExit, + onNavigateToSubFilter: (navContext, filter, initialSelection) async => + Navigator.of(navContext).push?>( + catalogSubFilterRoute( + filter: filter, + initialSelection: initialSelection, + ), + ), + ), + ); + +/// Returns a [MaterialPageRoute] for the catalog filter screen. +MaterialPageRoute catalogFilterRoute() => MaterialPageRoute( + builder: (context) => CatalogFilterView( + onExit: () => Navigator.of(context).pop(), + onNavigateToSubFilter: (navContext, filter, initialSelection) async => + Navigator.of(navContext).push?>( + catalogSubFilterRoute( + filter: filter, + initialSelection: initialSelection, + ), + ), + ), + ); + +/// Returns a [MaterialPageRoute] for the sub-filter selection screen. +/// This route can return a `List` of the selected keys. +MaterialPageRoute?> catalogSubFilterRoute({ + required DataSourceMultiSelectFilter filter, + required List initialSelection, +}) => + MaterialPageRoute( + builder: (context) => CatalogSubFilterView( + filter: filter, + initialSelection: initialSelection, + ), + ); + +/// Navigates to a new screen within the user story's [Navigator]. +Future _routeToScreen(BuildContext context, Widget screen) async => + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => screen), + ); diff --git a/packages/flutter_catalog/lib/src/services/catalog_service.dart b/packages/flutter_catalog/lib/src/services/catalog_service.dart new file mode 100644 index 0000000..fa56203 --- /dev/null +++ b/packages/flutter_catalog/lib/src/services/catalog_service.dart @@ -0,0 +1,87 @@ +import "package:flutter_catalog_interface/flutter_catalog_interface.dart"; + +/// A service class to manage catalog-related operations. +/// +/// This class is generic and uses a [CatalogRepository] to fetch and +/// manipulate catalog data of type T. +class CatalogService { + /// Creates a [CatalogService] with the given repository. + const CatalogService({ + required CatalogRepository repository, + required CatalogUserRepository userRepository, + required this.userId, + required this.userLocation, + }) : _repository = repository, + _userRepository = userRepository; + + final CatalogRepository _repository; + + final CatalogUserRepository _userRepository; + + /// The ID of the user for whom catalog operations are performed. + final String userId; + + /// The user's current location, if available. + final LatLng? userLocation; + + /// Retrieves catalog items, optionally applying filters and user location. + Future> fetchCatalogItems({ + Map? filters, + int? limit, + int? offset, + }) async => + _repository.fetchCatalogItems( + userId: userId, + userLocation: userLocation, + filters: filters, + limit: limit, + offset: offset, + ); + + /// Fetches users for a list of catalog items. + Future> fetchUsersForItems( + List items, + ) async { + var userIds = + items.map((item) => item.authorId).whereType().toSet().toList(); + return _userRepository.getUsers(userIds); + } + + /// Retrieves a user for a specific catalog item. + Future getUserForCatalogItem(T item) async { + if (item.authorId == null) return null; + return _userRepository.getUser(item.authorId!); + } + + /// Toggles the favorite status of an item for the current user. + Future toggleFavorite(String itemId) async => + _repository.toggleFavorite(itemId, userId); + + /// Fetches a single catalog item by its ID. + Future fetchCatalogItemById(String id) async => + _repository.fetchCatalogItemById(id, userId); + + /// Creates a new catalog item. + Future createCatalogItem(Map item) async { + var dataWithLocation = Map.from(item); + if (userLocation != null) { + dataWithLocation["location"] = userLocation!.toJson(); + } + return _repository.createCatalogItem(dataWithLocation); + } + + /// Updates an existing catalog item by its ID. + Future updateCatalogItem( + String itemId, + Map item, + ) async => + _repository.updateCatalogItem(itemId, item); + + /// Deletes a catalog item by its ID. + Future deleteCatalogItem(String itemId) async => + _repository.deleteCatalogItem(itemId); + + /// Uploads an image file. + Future uploadImage(XFile imageFile) async => + _repository.uploadImage(imageFile); +} diff --git a/packages/flutter_catalog/lib/src/services/pop_handler.dart b/packages/flutter_catalog/lib/src/services/pop_handler.dart new file mode 100644 index 0000000..abd0aba --- /dev/null +++ b/packages/flutter_catalog/lib/src/services/pop_handler.dart @@ -0,0 +1,24 @@ +import "package:flutter/material.dart"; + +/// +class PopHandler { + /// Constructor + PopHandler(); + + final List _handlers = []; + + /// Registers a new handler + void register(VoidCallback handler) { + _handlers.add(handler); + } + + /// Removes a handler + void unregister(VoidCallback handler) { + _handlers.remove(handler); + } + + /// Handles the pop + void handlePop() { + _handlers.lastOrNull?.call(); + } +} diff --git a/packages/flutter_catalog/lib/src/utils/scope.dart b/packages/flutter_catalog/lib/src/utils/scope.dart new file mode 100644 index 0000000..fc49f94 --- /dev/null +++ b/packages/flutter_catalog/lib/src/utils/scope.dart @@ -0,0 +1,42 @@ +import "package:dart_feed_utilities/dart_feed_utilities.dart"; +import "package:flutter/widgets.dart"; +import "package:flutter_catalog/src/config/catalog_options.dart"; +import "package:flutter_catalog/src/services/catalog_service.dart"; +import "package:flutter_catalog/src/services/pop_handler.dart"; + +/// +class CatalogScope extends InheritedWidget { + /// + const CatalogScope({ + required this.userId, + required this.options, + required this.catalogService, + required this.filterService, + required this.popHandler, + required super.child, + super.key, + }); + + /// + final String userId; + + /// + final CatalogOptions options; + + /// + final CatalogService catalogService; + + /// + final FilterService filterService; + + /// + final PopHandler popHandler; + + @override + bool updateShouldNotify(CatalogScope oldWidget) => + oldWidget.userId != userId || oldWidget.options != options; + + /// + static CatalogScope of(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()!; +} diff --git a/packages/flutter_catalog/lib/src/views/catalog_detail_view.dart b/packages/flutter_catalog/lib/src/views/catalog_detail_view.dart new file mode 100644 index 0000000..a9028ff --- /dev/null +++ b/packages/flutter_catalog/lib/src/views/catalog_detail_view.dart @@ -0,0 +1,438 @@ +import "package:cached_network_image/cached_network_image.dart"; +import "package:flutter/material.dart"; +import "package:flutter_catalog/l10n/app_localizations.dart"; +import "package:flutter_catalog/src/config/screen_types.dart"; +import "package:flutter_catalog/src/utils/scope.dart"; +import "package:flutter_catalog/src/widgets/image_view_carousel.dart"; +import "package:flutter_catalog_interface/flutter_catalog_interface.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:intl/intl.dart"; + +/// A view that displays the full details of a single [CatalogItem]. +class CatalogDetailView extends HookWidget { + /// Creates a [CatalogDetailView]. + const CatalogDetailView({ + required this.item, + required this.onEditItem, + super.key, + this.onExit, + }); + + /// The item to display. + final CatalogItem item; + + /// A callback to execute when the user wants to navigate back. + final VoidCallback? onExit; + + /// A callback to execute when the user wants to edit the item. + final void Function(CatalogItem item) onEditItem; + + @override + Widget build(BuildContext context) { + var scope = CatalogScope.of(context); + var options = scope.options; + var service = scope.catalogService; + var isAuthor = scope.userId == item.authorId; + var isFavorite = useState(item.isFavorited ?? false); + + // ignore: discarded_futures + var authorFuture = useMemoized( + () async => service.getUserForCatalogItem(item), + [item.authorId], + ); + + var authorSnapshot = useFuture(authorFuture); + + useEffect( + () { + if (onExit == null) return null; + scope.popHandler.register(onExit!); + return () => scope.popHandler.unregister(onExit!); + }, + [onExit], + ); + + Future toggleFavorite() async { + isFavorite.value = !isFavorite.value; + try { + await service.toggleFavorite(item.id); + } on Exception catch (_) { + if (!context.mounted) return; + isFavorite.value = !isFavorite.value; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Could not update favorite status.")), + ); + } + } + + var appBar = AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: onExit, + ), + actions: [ + if (!isAuthor) ...[ + IconButton( + icon: Icon( + isFavorite.value ? Icons.favorite : Icons.favorite_border, + color: Colors.pink, + ), + onPressed: toggleFavorite, + ), + ], + ], + ); + + var body = SingleChildScrollView( + child: isAuthor + ? _MyItemDetailBody( + item: item, + onEditItem: onEditItem, + ) + : _OtherUserItemDetailBody( + item: item, + author: authorSnapshot.data, + ), + ); + + if (options.builders.baseScreenBuilder != null) { + return options.builders.baseScreenBuilder!( + context, + ScreenType.catalogDetail, + appBar, + item.title, + body, + ); + } + + return Scaffold( + appBar: appBar, + body: body, + ); + } +} + +/// The layout for viewing an item owned by the current user. +class _MyItemDetailBody extends StatelessWidget { + const _MyItemDetailBody({required this.item, required this.onEditItem}); + final CatalogItem item; + final void Function(CatalogItem item) onEditItem; + + @override + Widget build(BuildContext context) { + var options = CatalogScope.of(context).options; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ImageCarousel(mediaUrls: item.imageUrls), + _TitleSection(item: item), + _EditSection(onEdit: () => onEditItem(item)), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (item.description.isNotEmpty) ...[ + _DescriptionSection(item: item), + const SizedBox(height: 24), + ], + if (item.customFields.isNotEmpty) ...[ + _TagsSection(customFields: item.customFields), + const SizedBox(height: 24), + ], + if (options.builders.detailPageItemBuilder != null) ...[ + options.builders.detailPageItemBuilder!(context, item), + ], + const SizedBox(height: 18), + _PostedDate(date: item.postedAt), + ], + ), + ), + ], + ); + } +} + +class _TitleSection extends StatelessWidget { + const _TitleSection({ + required this.item, + }); + + final CatalogItem item; + + @override + Widget build(BuildContext context) { + var options = CatalogScope.of(context).options; + var localizations = FlutterCatalogLocalizations.of(context)!; + + return Container( + decoration: BoxDecoration( + color: options.theme.authorSectionBackgroundColor ?? + Theme.of(context).colorScheme.primary.withOpacity(0.4), + ), + padding: const EdgeInsets.all(20), + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 2), + Text( + item.price != null && item.price! > 0 + ? "€${item.price!.toStringAsFixed(2)}" + : localizations.priceFree, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ); + } +} + +/// The layout for viewing an item owned by another user. +class _OtherUserItemDetailBody extends StatelessWidget { + const _OtherUserItemDetailBody({required this.item, this.author}); + final CatalogItem item; + final CatalogUser? author; + + @override + Widget build(BuildContext context) { + var options = CatalogScope.of(context).options; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ImageCarousel(mediaUrls: item.imageUrls), + _AuthorSection(author: author), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _DescriptionSection(item: item), + const SizedBox(height: 24), + if (item.customFields.isNotEmpty) ...[ + _TagsSection(customFields: item.customFields), + const SizedBox(height: 24), + ], + if (options.builders.detailPageItemBuilder != null) + options.builders.detailPageItemBuilder!(context, item) + else + _MapSection(), + const SizedBox(height: 24), + _PostedDate(date: item.postedAt), + ], + ), + ), + ], + ); + } +} + +class _AuthorSection extends StatelessWidget { + const _AuthorSection({required this.author}); + final CatalogUser? author; + + @override + Widget build(BuildContext context) { + var options = CatalogScope.of(context).options; + var localizations = FlutterCatalogLocalizations.of(context)!; + var textTheme = Theme.of(context).textTheme; + var backgroundColor = options.theme.authorSectionBackgroundColor ?? + Theme.of(context).colorScheme.primary.withOpacity(0.4); + + return Container( + color: backgroundColor, + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + CircleAvatar( + radius: 24, + backgroundImage: (author?.avatarUrl?.isNotEmpty ?? false) + ? CachedNetworkImageProvider(author!.avatarUrl!) + : null, + child: (author?.avatarUrl?.isEmpty ?? true) + ? const Icon(Icons.person) + : null, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + author?.name ?? "Unknown User", + style: + textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + if (author != null) ...[ + options.builders.primaryButtonBuilder( + context, + onPressed: () => options.onPressContactUser?.call(author!), + onDisabledPressed: () { + if (options.onPressContactUser == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(localizations.contactUserDisabledMessage), + ), + ); + } + }, + isDisabled: options.onPressContactUser == null, + child: Text(localizations.sendMessageButton), + ), + ], + ], + ), + ); + } +} + +class _DescriptionSection extends StatelessWidget { + const _DescriptionSection({required this.item}); + final CatalogItem item; + + @override + Widget build(BuildContext context) { + var localizations = FlutterCatalogLocalizations.of(context)!; + var textTheme = Theme.of(context).textTheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localizations.detailDescriptionTitle, + style: textTheme.titleSmall, + ), + const SizedBox(height: 8), + Text(item.description, style: textTheme.bodyMedium), + ], + ); + } +} + +class _TagsSection extends StatelessWidget { + const _TagsSection({required this.customFields}); + final Map customFields; + + @override + Widget build(BuildContext context) { + var localizations = FlutterCatalogLocalizations.of(context)!; + var textTheme = Theme.of(context).textTheme; + var chips = []; + + customFields.forEach((key, value) { + if (value is String) { + chips.add(Chip(label: Text(value))); + } else if (value is List) { + for (var val in value) { + chips.add(Chip(label: Text(val.toString()))); + } + } + }); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localizations.characteristicsTitle, + style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8.0, + runSpacing: 4.0, + children: chips, + ), + ], + ); + } +} + +class _MapSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + var localizations = FlutterCatalogLocalizations.of(context)!; + var textTheme = Theme.of(context).textTheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(localizations.distanceTitle, style: textTheme.titleLarge), + const SizedBox(height: 8), + AspectRatio( + aspectRatio: 16 / 9, + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: Text("Map Placeholder"), + ), + ), + ), + ], + ); + } +} + +class _EditSection extends StatelessWidget { + const _EditSection({required this.onEdit}); + final VoidCallback onEdit; + + @override + Widget build(BuildContext context) { + var localizations = FlutterCatalogLocalizations.of(context)!; + return InkWell( + onTap: onEdit, + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.secondary, + ), + ), + width: double.infinity, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + children: [ + const Icon(Icons.edit, size: 24), + const SizedBox(height: 4), + Text(localizations.editItemButton), + ], + ), + ), + ), + ); + } +} + +class _PostedDate extends StatelessWidget { + const _PostedDate({this.date}); + final DateTime? date; + + @override + Widget build(BuildContext context) { + if (date == null) return const SizedBox.shrink(); + + var localizations = FlutterCatalogLocalizations.of(context)!; + var formattedDate = DateFormat.yMd().format(date!); + + return Align( + alignment: Alignment.centerLeft, + child: Text( + localizations.postedSince(formattedDate), + style: Theme.of(context).textTheme.bodySmall, + ), + ); + } +} diff --git a/packages/flutter_catalog/lib/src/views/catalog_filter_view.dart b/packages/flutter_catalog/lib/src/views/catalog_filter_view.dart new file mode 100644 index 0000000..9dfa449 --- /dev/null +++ b/packages/flutter_catalog/lib/src/views/catalog_filter_view.dart @@ -0,0 +1,380 @@ +import "package:dart_feed_utilities/filters.dart"; +import "package:flutter/material.dart"; +import "package:flutter_catalog/l10n/app_localizations.dart"; +import "package:flutter_catalog/src/config/screen_types.dart"; +import "package:flutter_catalog/src/routes.dart"; +import "package:flutter_catalog/src/utils/scope.dart"; +import "package:flutter_catalog/src/widgets/inputs/checkbox_input_section.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; + +/// A view that allows the user to configure and apply filters. +class CatalogFilterView extends HookWidget { + /// Creates a [CatalogFilterView]. + const CatalogFilterView({ + required this.onNavigateToSubFilter, + this.onExit, + super.key, + }); + + /// The callback to execute when the user leaves the screen. + final VoidCallback? onExit; + + /// A callback to handle navigating to a sub-filter selection screen. + final NavigateToSubFilterCallback onNavigateToSubFilter; + + @override + Widget build(BuildContext context) { + var scope = CatalogScope.of(context); + var options = scope.options; + var localizations = FlutterCatalogLocalizations.of(context)!; + var filterService = scope.filterService; + + var localFilterValues = useState>({}); + + var filtersStream = useMemoized( + () => filterService.getFiltersWithValues(), + [], + ); + var snapshot = useStream(filtersStream); + + void applyFilters() { + localFilterValues.value.forEach((filterId, value) async { + if (value != null) { + var filter = snapshot.data! + .firstWhere((f) => f.filterModel.id == filterId) + .filterModel; + await filterService.setFilterValue(filter, value); + } + }); + onExit?.call(); + } + + useEffect( + () { + if (snapshot.hasData) { + localFilterValues.value = { + for (var f in snapshot.data!) f.filterModel.id: f.value?.value, + }; + } + return; + }, + [snapshot.data], + ); + + useEffect( + () { + if (onExit == null) return null; + scope.popHandler.register(onExit!); + return () => scope.popHandler.unregister(onExit!); + }, + [onExit], + ); + + var appBar = AppBar( + title: Text(localizations.filtersTitle), + centerTitle: true, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: onExit, + ), + ); + + var displayableFilters = snapshot.data + ?.where((filter) => filter.filterModel.id != "search") + .toList(); + + var body = !snapshot.hasData + ? const Center(child: CircularProgressIndicator.adaptive()) + : _FilterBody( + filters: displayableFilters ?? [], + localFilterValues: localFilterValues, + onApplyFilters: applyFilters, + onNavigateToSubFilter: onNavigateToSubFilter, + ); + + if (options.builders.baseScreenBuilder != null) { + return options.builders.baseScreenBuilder!( + context, + ScreenType.catalogFilter, + appBar, + localizations.filtersTitle, + body, + ); + } + + return Scaffold( + appBar: appBar, + body: body, + ); + } +} + +class _FilterBody extends StatelessWidget { + const _FilterBody({ + required this.filters, + required this.localFilterValues, + required this.onApplyFilters, + required this.onNavigateToSubFilter, + }); + + final List filters; + final ValueNotifier> localFilterValues; + final VoidCallback onApplyFilters; + final NavigateToSubFilterCallback onNavigateToSubFilter; + + @override + Widget build(BuildContext context) { + var localizations = FlutterCatalogLocalizations.of(context)!; + var options = CatalogScope.of(context).options; + var builders = options.builders; + + return Column( + children: [ + Expanded( + child: ListView.builder( + itemCount: filters.length, + itemBuilder: (context, index) { + var filterWithValue = filters[index]; + var filter = filterWithValue.filterModel; + + var translatedName = + options.filterOptions?.translator?.call(filter.name) ?? + filter.name; + var child = _buildFilterControls( + context, + filter, + translatedName, + localFilterValues, + ); + + return builders.filterSectionBuilder?.call( + context, + translatedName, + child, + ) ?? + child; + }, + ), + ), + SizedBox( + width: double.infinity, + child: builders.primaryButtonBuilder( + context, + onPressed: () => onApplyFilters, + isDisabled: false, + onDisabledPressed: () {}, + child: Text(localizations.applyFiltersButton), + ), + ), + ], + ); + } + + /// Chooses which widget to build based on the filter's type. + Widget _buildFilterControls( + BuildContext context, + FilterModel filter, + String translatedName, + ValueNotifier> localFilterValues, + ) => + switch (filter) { + BooleanSelectFilter _ => _BooleanFilterSwitch( + filter: filter, + translatedName: translatedName, + localFilterValues: localFilterValues, + ), + MinMaxIntFilter _ => _RangeSliderFilter( + filter: filter, + translatedName: translatedName, + localFilterValues: localFilterValues, + ), + DataSourceMultiSelectFilter f when f.isNested || f.isSearchEnabled => + _NavigableFilter( + filter: filter, + translatedName: translatedName, + localFilterValues: localFilterValues, + onNavigateToSubFilter: onNavigateToSubFilter, + ), + DataSourceMultiSelectFilter _ => _DataSourceCheckboxFilter( + filter: filter, + translatedName: translatedName, + localFilterValues: localFilterValues, + ), + _ => const SizedBox.shrink(), + }; +} + +/// A new wrapper widget that fetches data for a filter and renders the +/// generic CheckboxInputSection. +class _DataSourceCheckboxFilter extends HookWidget { + const _DataSourceCheckboxFilter({ + required this.filter, + required this.translatedName, + required this.localFilterValues, + }); + + final DataSourceMultiSelectFilter filter; + final String translatedName; + final ValueNotifier> localFilterValues; + + @override + Widget build(BuildContext context) { + var scope = CatalogScope.of(context); + // Fetch the options (e.g., Conditions, Age Ranges) for this filter + // ignore: discarded_futures + var optionsFuture = useMemoized( + () async => scope.filterService.getDataForDatasource(filter.dataSource), + [filter.dataSource], + ); + var snapshot = useFuture(optionsFuture); + + if (!snapshot.hasData) + return const Center(child: CircularProgressIndicator.adaptive()); + + var options = snapshot.data!; + var currentSelection = + (localFilterValues.value[filter.id] as List?) + ?.cast() ?? + []; + + var crossAxisCount = filter.metadata["gridCrossAxisCount"] as int? ?? 1; + + return CheckboxInputSection( + title: translatedName, + options: options, + gridCrossAxisCount: crossAxisCount, + selectedKeys: currentSelection, + keySelector: (option) => option.key, + labelSelector: (option) => option.name, + isMultiSelect: filter.isMultiSelect, + onOptionToggled: (toggledKey) { + var newSelection = List.from(currentSelection); + if (newSelection.contains(toggledKey)) { + newSelection.remove(toggledKey); + } else { + if (filter.isMultiSelect) { + newSelection.add(toggledKey); + } else { + newSelection = [toggledKey]; + } + } + var newMap = Map.from(localFilterValues.value); + newMap[filter.id] = newSelection; + localFilterValues.value = newMap; + }, + ); + } +} + +class _RangeSliderFilter extends StatelessWidget { + const _RangeSliderFilter({ + required this.filter, + required this.translatedName, + required this.localFilterValues, + }); + final MinMaxIntFilter filter; + final String translatedName; + final ValueNotifier> localFilterValues; + + @override + Widget build(BuildContext context) { + var currentValue = (localFilterValues.value[filter.id] as (int, int?)?) ?? + (filter.defaultValue, filter.isRange ? filter.defaultValue : null); + + if (filter.isRange) { + var currentRange = RangeValues( + currentValue.$1.toDouble(), + (currentValue.$2 ?? filter.max).toDouble(), + ); + return RangeSlider( + values: currentRange, + min: filter.min.toDouble(), + max: filter.max.toDouble(), + divisions: filter.max - filter.min, + labels: RangeLabels( + currentRange.start.round().toString(), + currentRange.end.round().toString(), + ), + onChanged: (RangeValues values) { + var newMap = Map.from(localFilterValues.value); + newMap[filter.id] = (values.start.round(), values.end.round()); + localFilterValues.value = newMap; + }, + ); + } else { + return const SizedBox.shrink(); + } + } +} + +class _NavigableFilter extends StatelessWidget { + const _NavigableFilter({ + required this.filter, + required this.translatedName, + required this.localFilterValues, + required this.onNavigateToSubFilter, + }); + final DataSourceMultiSelectFilter filter; + final String translatedName; + final ValueNotifier> localFilterValues; + final NavigateToSubFilterCallback onNavigateToSubFilter; + + @override + Widget build(BuildContext context) { + var currentSelection = + (localFilterValues.value[filter.id] as List?) + ?.cast() ?? + []; + + return ListTile( + title: Text(filter.name), + subtitle: currentSelection.isNotEmpty + ? Text("${currentSelection.length} selected") + : null, + trailing: const Icon(Icons.chevron_right), + onTap: () async { + var newSelection = await onNavigateToSubFilter.call( + context, + filter, + currentSelection, + ); + + // If the user confirmed a new selection, update the state. + if (newSelection != null) { + var newMap = Map.from(localFilterValues.value); + newMap[filter.id] = newSelection; + localFilterValues.value = newMap; + } + }, + ); + } +} + +class _BooleanFilterSwitch extends StatelessWidget { + const _BooleanFilterSwitch({ + required this.filter, + required this.translatedName, + required this.localFilterValues, + }); + + final BooleanSelectFilter filter; + final String translatedName; + final ValueNotifier> localFilterValues; + + @override + Widget build(BuildContext context) { + // Get the current value from the local state, defaulting to false. + var currentValue = localFilterValues.value[filter.id] as bool? ?? false; + + return SwitchListTile( + title: Text(filter.name), + value: currentValue, + onChanged: (newValue) { + // Update the local state when the switch is toggled. + var newMap = Map.from(localFilterValues.value); + newMap[filter.id] = newValue; + localFilterValues.value = newMap; + }, + ); + } +} diff --git a/packages/flutter_catalog/lib/src/views/catalog_modify_view.dart b/packages/flutter_catalog/lib/src/views/catalog_modify_view.dart new file mode 100644 index 0000000..3e22c4f --- /dev/null +++ b/packages/flutter_catalog/lib/src/views/catalog_modify_view.dart @@ -0,0 +1,395 @@ +import "package:dart_feed_utilities/dart_feed_utilities.dart"; +import "package:flutter/material.dart"; +import "package:flutter_catalog/flutter_catalog.dart"; +import "package:flutter_catalog/src/models/item_view_model.dart"; +import "package:flutter_catalog/src/routes.dart"; +import "package:flutter_catalog/src/widgets/inputs/checkbox_input_section.dart"; +import "package:flutter_catalog/src/widgets/inputs/image_selection_widget.dart"; +import "package:flutter_catalog/src/widgets/inputs/input_section.dart"; +import "package:flutter_catalog/src/widgets/inputs/range_slider_input_section.dart"; +import "package:flutter_catalog/src/widgets/inputs/text_input_section.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; + +/// Padding to apply on the sides of the item creation screen. +const double itemModificationScreenSidePadding = 20.0; + +/// A screen for creating a new catalog item or editing an existing one. +class CatalogModifyView extends HookWidget { + /// + const CatalogModifyView({ + required this.onNavigateToSubFilter, + this.initialItem, + this.onExit, + super.key, + }); + + /// The item to be edited. If null, the screen is in "create" mode. + final CatalogItem? initialItem; + + /// A callback to execute when the screen is closed. + final VoidCallback? onExit; + + /// A callback to navigate to a sub-filter selection screen. + final NavigateToSubFilterCallback onNavigateToSubFilter; + + /// + bool get isEditing => initialItem != null; + + @override + Widget build(BuildContext context) { + var itemData = useState( + isEditing + ? ItemModificationData.fromItem(initialItem!) + : const ItemModificationData(), + ); + var isLoading = useState(false); + var scope = CatalogScope.of(context); + var options = scope.options; + var localizations = FlutterCatalogLocalizations.of(context)!; + var navigator = Navigator.of(context); + + // ignore: discarded_futures + var filtersFuture = useMemoized(() => scope.filterService.getFilters(), []); + var filtersSnapshot = useFuture(filtersFuture); + + var validationError = itemData.value.validate(localizations); + var isFormValid = validationError == null; + var isButtonDisabled = isLoading.value || !isFormValid; + + void onDisabledPressed() { + if (!isFormValid) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(validationError)), + ); + } + } + + var buttonChild = Text( + isLoading.value + ? localizations.itemCreatePageSavingChangesButton + : localizations.itemCreatePageSaveChangesButton, + ); + + Future handleSubmit() async { + isLoading.value = true; + try { + var uploadedImageUrls = await Future.wait( + itemData.value.newImages + .map((file) => scope.catalogService.uploadImage(file)), + ); + + var dataMap = itemData.value.toJson( + allImageUrls: [ + ...itemData.value.existingImageUrls, + ...uploadedImageUrls, + ], + ); + + if (isEditing) { + await scope.catalogService + .updateCatalogItem(initialItem!.id, dataMap); + } else { + await scope.catalogService.createCatalogItem(dataMap); + } + onExit?.call(); + } on Exception catch (_) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(localizations.itemCreatePageGenericError)), + ); + } finally { + isLoading.value = false; + } + } + + Future handleDelete() async { + var confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(localizations.itemCreatePageDeleteConfirmationTitle), + content: Text(localizations.itemCreatePageDeleteConfirmationMessage), + actions: [ + TextButton( + onPressed: () => navigator.pop(false), + child: Text(localizations.itemCreatePageDeleteConfirmationCancel), + ), + TextButton( + onPressed: () => navigator.pop(true), + child: Text( + localizations.itemCreatePageDeleteConfirmationConfirm, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ), + ); + + if (confirmed ?? false) { + isLoading.value = true; + try { + await scope.catalogService.deleteCatalogItem(initialItem!.id); + onExit?.call(); + } on Exception catch (_) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(localizations.itemCreatePageItemDeleteError), + ), + ); + } finally { + isLoading.value = false; + } + } + } + + var saveButton = options.builders.primaryButtonBuilder( + context, + onPressed: handleSubmit, + onDisabledPressed: onDisabledPressed, + isDisabled: isButtonDisabled, + child: buttonChild, + ); + + var appBar = AppBar( + title: Text( + isEditing + ? localizations.itemEditPageTitle + : localizations.itemCreatePageTitle, + ), + ); + + var body = SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: itemModificationScreenSidePadding, + vertical: 16, + ), + child: ImageSelection( + existingImageUrls: itemData.value.existingImageUrls, + newImages: itemData.value.newImages, + onNewImagesChanged: (list) => + itemData.value = itemData.value.copyWith(newImages: list), + onExistingImagesChanged: (list) => itemData.value = + itemData.value.copyWith(existingImageUrls: list), + ), + ), + const SizedBox(height: 24), + TextInputSection( + title: localizations.itemCreatePageTitleHint, + label: localizations.itemCreatePageTitleHint, + value: itemData.value.title, + mandatory: true, + onChanged: (value) => + itemData.value = itemData.value.copyWith(title: value), + ), + TextInputSection( + title: localizations.itemCreatePageDescriptionHint, + label: localizations.itemCreatePageDescriptionHint, + value: itemData.value.description, + onChanged: (value) => + itemData.value = itemData.value.copyWith(description: value), + ), + const SizedBox(height: 24), + if (filtersSnapshot.hasData) ...[ + ...filtersSnapshot.data!.where((f) => f.id != "search").map( + (filter) => _buildFilterInput(context, filter, itemData), + ), + ], + Padding( + padding: const EdgeInsets.symmetric( + horizontal: itemModificationScreenSidePadding, + vertical: 16, + ), + child: saveButton, + ), + if (isEditing) ...[ + const SizedBox(height: 16), + TextButton( + onPressed: isLoading.value ? null : handleDelete, + child: Text( + localizations.itemCreatePageDeleteItemButton, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ], + ), + ); + if (options.builders.baseScreenBuilder != null) { + return options.builders.baseScreenBuilder!( + context, + ScreenType.catalogModify, + appBar, + isEditing + ? localizations.itemEditPageTitle + : localizations.itemCreatePageTitle, + body, + ); + } + return Scaffold(appBar: appBar, body: body); + } + + /// The main builder method that decides which input widget to render + /// for a given filter definition. + Widget _buildFilterInput( + BuildContext context, + FilterModel filter, + ValueNotifier itemData, + ) => + switch (filter) { + MinMaxIntFilter _ => + _MinMaxFilterInput(filter: filter, itemData: itemData), + DataSourceMultiSelectFilter f when f.isNested || f.isSearchEnabled => + _NavigableInputSection( + filter: filter, + itemData: itemData, + onNavigate: onNavigateToSubFilter, + ), + DataSourceMultiSelectFilter _ => + _DataSourceFilterInput(filter: filter, itemData: itemData), + _ => const SizedBox.shrink(), + }; +} + +/// A wrapper that provides data and logic for a checkbox-based filter input. +class _DataSourceFilterInput extends HookWidget { + const _DataSourceFilterInput({required this.filter, required this.itemData}); + final DataSourceMultiSelectFilter filter; + final ValueNotifier itemData; + + @override + Widget build(BuildContext context) { + var scope = CatalogScope.of(context); + var options = scope.options; + // ignore: discarded_futures + var dataSourceFuture = useMemoized( + () async => scope.filterService.getDataForDatasource(filter.dataSource), + [filter.dataSource], + ); + var snapshot = useFuture(dataSourceFuture); + + if (!snapshot.hasData) return const SizedBox.shrink(); + + var currentSelection = + (itemData.value.customFieldValues[filter.key] as List?) + ?.cast() ?? + []; + var translatedName = + options.filterOptions?.translator?.call(filter.name) ?? filter.name; + + return CheckboxInputSection( + title: translatedName, + options: snapshot.data!, + selectedKeys: currentSelection, + keySelector: (option) => option.key, + labelSelector: (option) => option.name, + isMultiSelect: filter.isMultiSelect, + gridCrossAxisCount: filter.metadata["gridCrossAxisCount"] as int? ?? 1, + onOptionToggled: (toggledKey) { + var newSelection = List.from(currentSelection); + if (newSelection.contains(toggledKey)) { + newSelection.remove(toggledKey); + } else { + if (filter.isMultiSelect) { + newSelection.add(toggledKey); + } else { + newSelection = [toggledKey]; + } + } + var newCustomValues = + Map.from(itemData.value.customFieldValues); + newCustomValues[filter.key] = newSelection; + itemData.value = + itemData.value.copyWith(customFieldValues: newCustomValues); + }, + ); + } +} + +/// A wrapper that provides data and logic for a range-slider-based +/// filter input. +class _MinMaxFilterInput extends HookWidget { + const _MinMaxFilterInput({required this.filter, required this.itemData}); + final MinMaxIntFilter filter; + final ValueNotifier itemData; + + @override + Widget build(BuildContext context) { + var options = CatalogScope.of(context).options; + var translatedName = + options.filterOptions?.translator?.call(filter.name) ?? filter.name; + + var currentValue = + (itemData.value.customFieldValues[filter.key] as (int, int?)?) ?? + (filter.defaultValue, filter.isRange ? filter.defaultValue : null); + + return RangeSliderInputSection( + title: translatedName, + min: filter.min, + max: filter.max, + values: RangeValues( + currentValue.$1.toDouble(), + (currentValue.$2 ?? filter.max).toDouble(), + ), + onChanged: (values) { + var newCustomValues = + Map.from(itemData.value.customFieldValues); + newCustomValues[filter.key] = + (values.start.round(), values.end.round()); + itemData.value = + itemData.value.copyWith(customFieldValues: newCustomValues); + }, + ); + } +} + +/// A new widget that renders a clickable row for filters that +/// require navigation. +class _NavigableInputSection extends HookWidget { + const _NavigableInputSection({ + required this.filter, + required this.itemData, + required this.onNavigate, + }); + + final DataSourceMultiSelectFilter filter; + final ValueNotifier itemData; + final NavigateToSubFilterCallback onNavigate; + + @override + Widget build(BuildContext context) { + var scope = CatalogScope.of(context); + var options = scope.options; + var translatedName = + options.filterOptions?.translator?.call(filter.name) ?? filter.name; + var currentSelection = + (itemData.value.customFieldValues[filter.key] as List?) + ?.cast() ?? + []; + + return InputSection( + title: translatedName, + // You can add logic here to show the names of selected items + input: Text("${currentSelection.length} selected"), + onTap: () async { + var newSelection = await onNavigate( + context, + filter, + currentSelection, + ); + + // If the sub-page returned a new selection, update the form state. + if (newSelection != null) { + var newCustomValues = + Map.from(itemData.value.customFieldValues); + newCustomValues[filter.key] = newSelection; + itemData.value = + itemData.value.copyWith(customFieldValues: newCustomValues); + } + }, + ); + } +} diff --git a/packages/flutter_catalog/lib/src/views/catalog_overview_view.dart b/packages/flutter_catalog/lib/src/views/catalog_overview_view.dart new file mode 100644 index 0000000..7dbaec6 --- /dev/null +++ b/packages/flutter_catalog/lib/src/views/catalog_overview_view.dart @@ -0,0 +1,330 @@ +import "dart:async"; + +import "package:dart_feed_utilities/dart_feed_utilities.dart"; +import "package:flutter/material.dart"; +import "package:flutter_catalog/l10n/app_localizations.dart"; +import "package:flutter_catalog/src/config/screen_types.dart"; +import "package:flutter_catalog/src/utils/scope.dart"; +import "package:flutter_catalog/src/widgets/catalog_grid_item.dart"; +import "package:flutter_catalog_interface/flutter_catalog_interface.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; + +/// +class CatalogOverviewView extends HookWidget { + /// + const CatalogOverviewView({ + required this.onPressItem, + required this.onPressFilters, + this.onExit, + super.key, + }); + + /// + final void Function(CatalogItem item) onPressItem; + + /// + final VoidCallback onPressFilters; + + /// + final VoidCallback? onExit; + + @override + Widget build(BuildContext context) { + var scope = CatalogScope.of(context); + var options = scope.options; + var localizations = FlutterCatalogLocalizations.of(context)!; + + var searchController = useTextEditingController(); + + useEffect( + () { + if (onExit == null) return null; + scope.popHandler.register(onExit!); + return () => scope.popHandler.unregister(onExit!); + }, + [onExit], + ); + + var appBar = (options.filterOptions?.showSearchInOverview ?? true) + ? _AppBarWithSearch( + localizations: localizations, + searchController: searchController, + ) + : AppBar( + title: Text( + options.translations.overviewTitle ?? localizations.overviewTitle, + ), + ); + + var body = Column( + children: [ + if (options.filterOptions?.showFiltersInOverview ?? false) ...[ + _FilterBar( + localizations: localizations, + onPressFilters: onPressFilters, + ), + ], + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 16, + ), + child: _Body( + onPressItem: onPressItem, + ), + ), + ), + ], + ); + + if (options.builders.baseScreenBuilder != null) { + return options.builders.baseScreenBuilder!( + context, + ScreenType.catalogOverview, + appBar, + options.translations.overviewTitle ?? localizations.overviewTitle, + body, + ); + } + + return Scaffold( + appBar: appBar, + body: body, + ); + } +} + +class _AppBarWithSearch extends HookWidget implements PreferredSizeWidget { + const _AppBarWithSearch({ + required this.localizations, + required this.searchController, + }); + + final FlutterCatalogLocalizations localizations; + final TextEditingController searchController; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + var colorScheme = theme.colorScheme; + var scope = CatalogScope.of(context); + var filterService = scope.filterService; + var text = useValueListenable(searchController); + + var debouncedText = + useDebounced(text.text, const Duration(milliseconds: 500)); + + var previousDebouncedText = usePrevious(debouncedText); + + useEffect( + () { + if (debouncedText != null && debouncedText != previousDebouncedText) { + Future updateFilter() async { + var filters = await filterService.getFilters(); + var searchFilter = + filters.firstWhere((element) => element.id == "search"); + await filterService.setFilterValue(searchFilter, debouncedText); + } + + unawaited(updateFilter()); + } + return; + }, + [debouncedText], + ); + + return AppBar( + elevation: 0, + scrolledUnderElevation: 0, + flexibleSpace: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 12), + child: TextField( + controller: searchController, + decoration: InputDecoration( + hintText: localizations.searchHint, + suffixIcon: Icon( + Icons.search, + color: colorScheme.onSurfaceVariant, + ), + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(30.0), + borderSide: BorderSide( + color: colorScheme.outline, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(30.0), + borderSide: BorderSide( + color: colorScheme.outline.withOpacity(0.5), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(30.0), + borderSide: BorderSide( + color: colorScheme.primary, + ), + ), + hintStyle: TextStyle(color: colorScheme.onSurfaceVariant), + ), + style: TextStyle(color: colorScheme.onSurface), + ), + ), + ), + ); + } + + @override + Size get preferredSize => const Size.fromHeight(100); +} + +class _FilterBar extends StatelessWidget { + const _FilterBar({ + required this.localizations, + required this.onPressFilters, + }); + + final FlutterCatalogLocalizations localizations; + final VoidCallback onPressFilters; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + var options = CatalogScope.of(context).options; + + var backgroundColor = options.theme.filterBarBackgroundColor ?? + theme.colorScheme.secondaryContainer; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + color: backgroundColor, + child: Align( + alignment: Alignment.centerLeft, + child: ActionChip( + onPressed: onPressFilters, + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(localizations.filterButton), + const SizedBox(width: 5), + const Icon(Icons.filter_alt, size: 16), + ], + ), + ), + ), + ); + } +} + +class _Body extends HookWidget { + const _Body({ + required this.onPressItem, + }); + + final void Function(CatalogItem item) onPressItem; + + @override + Widget build(BuildContext context) { + var localizations = FlutterCatalogLocalizations.of(context)!; + + var scope = CatalogScope.of(context); + var catalogService = scope.catalogService; + var filterService = scope.filterService; + var filtersStream = + useMemoized(() => filterService.getFiltersWithValues(), []); + var filtersSnapshot = useStream(filtersStream); + var options = scope.options; + var builders = options.builders; + + // ignore: discarded_futures + var itemsFuture = useMemoized( + () async { + if (!filtersSnapshot.hasData) { + return catalogService.fetchCatalogItems(); + } + return catalogService.fetchCatalogItems( + filters: filtersSnapshot.data!.toSerializedFilterMap(), + ); + }, + [filtersSnapshot.data], + ); + var snapshot = useFuture(itemsFuture); + var items = snapshot.data; + useEffect( + () { + if (items == null || items.isEmpty) { + options.onNoItems?.call(); + } + return; + }, + [], + ); + + // ignore: discarded_futures + var usersFuture = useMemoized( + () async { + if (snapshot.hasData && snapshot.data!.isNotEmpty) { + return catalogService.fetchUsersForItems(snapshot.data!); + } + return Future.value([]); + }, + [snapshot.data], + ); + var usersSnapshot = useFuture(usersFuture); + var usersMap = { + for (var user in usersSnapshot.data ?? []) user.id: user, + }; + + if (snapshot.connectionState == ConnectionState.waiting) { + return builders.loadingIndicatorBuilder?.call(context) ?? + const Center(child: CircularProgressIndicator.adaptive()); + } + + if (snapshot.hasError) { + return builders.errorPlaceholderBuilder?.call(context, snapshot.error) ?? + Center(child: Text(localizations.itemLoadingError)); + } + + if (items == null || items.isEmpty) { + return builders.noItemsPlaceholderBuilder?.call(context) ?? + Center(child: Text(localizations.noItemsFound)); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + options.translations.overviewTitle ?? localizations.overviewTitle, + style: Theme.of(context).textTheme.titleMedium, + ), + Expanded( + child: GridView.builder( + padding: const EdgeInsets.only(top: 16), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + childAspectRatio: 0.7, + ), + itemCount: items.length, + itemBuilder: (context, index) { + var item = items[index]; + var author = usersMap[item.authorId]; + + return builders.itemCardBuilder + ?.call(context, item, () => onPressItem(item)) ?? + CatalogGridItem( + item: item, + author: author, + onTap: () => onPressItem(item), + ); + }, + ), + ), + ], + ); + } +} diff --git a/packages/flutter_catalog/lib/src/views/catalog_sub_filter_view.dart b/packages/flutter_catalog/lib/src/views/catalog_sub_filter_view.dart new file mode 100644 index 0000000..7235392 --- /dev/null +++ b/packages/flutter_catalog/lib/src/views/catalog_sub_filter_view.dart @@ -0,0 +1,179 @@ +import "package:dart_feed_utilities/dart_feed_utilities.dart"; +import "package:flutter/material.dart"; +import "package:flutter_catalog/flutter_catalog.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; + +/// A view that displays options for a specific data-driven filter, supporting +/// infinite nesting and returning the user's final selection. +class CatalogSubFilterView extends HookWidget { + /// Creates a [CatalogSubFilterView]. + /// + /// Requires the [filter] model and an [initialSelection] of keys. + /// The optional [parentData] is used for recursive navigation into + /// sub-categories. + const CatalogSubFilterView({ + required this.filter, + required this.initialSelection, + this.parentData, + super.key, + }); + + /// The filter model that defines the behavior of this view. + final DataSourceMultiSelectFilter filter; + + /// The list of keys that are selected when the view is first opened. + final List initialSelection; + + /// The parent data node, if this is a nested view. + final LinkedFilterData? parentData; + + @override + Widget build(BuildContext context) { + var scope = CatalogScope.of(context); + var options = scope.options; + var filterService = scope.filterService; + var localizations = FlutterCatalogLocalizations.of(context)!; + + var selectedValues = useState>(initialSelection); + var searchQuery = useState(""); + + // ignore: discarded_futures + var dataSourceFuture = useMemoized( + () async => filterService.getDataForDatasource(filter.dataSource), + [filter.dataSource], + ); + var snapshot = useFuture(dataSourceFuture); + + var appBar = AppBar( + title: Text(parentData?.name ?? filter.name), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + Navigator.of(context).pop(selectedValues.value); + }, + ), + ); + + var body = Builder( + builder: (context) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator.adaptive()); + } + if (snapshot.hasError || !snapshot.hasData || snapshot.data!.isEmpty) { + return const Center(child: Text("No options available.")); + } + + var allData = snapshot.data!; + var tree = allData.getDataAsTree(currentNode: parentData); + + var filteredTree = tree + .where( + (node) => node.current.name + .toLowerCase() + .contains(searchQuery.value.toLowerCase()), + ) + .toList(); + + return Column( + children: [ + if (filter.isSearchEnabled) ...[ + TextField( + onChanged: (value) => searchQuery.value = value, + decoration: InputDecoration( + hintText: localizations.searchHint, + prefixIcon: const Icon(Icons.search), + ), + ), + ], + Expanded( + child: ListView.builder( + itemCount: filteredTree.length, + itemBuilder: (context, index) { + var node = tree[index]; + var hasChildren = node.children.isNotEmpty; + + // Use a ListTile for more control over tap targets + return ListTile( + // The leading widget is now just the Checkbox + leading: Checkbox( + value: selectedValues.value.contains(node.current.key), + onChanged: (bool? selected) { + var currentSelection = + List.from(selectedValues.value); + if (selected ?? false) { + if (filter.isMultiSelect) { + currentSelection.add(node.current.key); + } else { + currentSelection + ..clear() + ..add(node.current.key); + } + } else { + currentSelection.remove(node.current.key); + } + selectedValues.value = currentSelection; + }, + ), + title: Text(node.current.name), + // The trailing widget is the navigation indicator + trailing: + hasChildren ? const Icon(Icons.chevron_right) : null, + // The onTap of the entire ListTile handles navigation + onTap: hasChildren + ? () async { + var result = + await Navigator.of(context).push>( + MaterialPageRoute( + builder: (context) => CatalogSubFilterView( + filter: filter, + parentData: node.current, + initialSelection: selectedValues.value, + ), + ), + ); + + if (result != null) { + selectedValues.value = result; + } + } + : () { + var currentSelection = + List.from(selectedValues.value); + if (currentSelection.contains(node.current.key)) { + currentSelection.remove(node.current.key); + } else { + if (filter.isMultiSelect) { + currentSelection.add(node.current.key); + } else { + currentSelection + ..clear() + ..add(node.current.key); + } + } + selectedValues.value = currentSelection; + }, + ); + }, + ), + ), + ], + ); + }, + ); + + if (options.builders.baseScreenBuilder != null) { + return options.builders.baseScreenBuilder!( + context, + ScreenType.catalogSubFilter, + appBar, + parentData?.name ?? filter.name, + body, + ); + } + + return Scaffold( + appBar: appBar, + body: body, + ); + } +} diff --git a/packages/flutter_catalog/lib/src/views/views.dart b/packages/flutter_catalog/lib/src/views/views.dart new file mode 100644 index 0000000..3f4b02b --- /dev/null +++ b/packages/flutter_catalog/lib/src/views/views.dart @@ -0,0 +1,5 @@ +export "./catalog_detail_view.dart"; +export "./catalog_filter_view.dart"; +export "./catalog_modify_view.dart"; +export "./catalog_overview_view.dart"; +export "./catalog_sub_filter_view.dart"; diff --git a/packages/flutter_catalog/lib/src/widgets/catalog_grid_item.dart b/packages/flutter_catalog/lib/src/widgets/catalog_grid_item.dart new file mode 100644 index 0000000..a45072e --- /dev/null +++ b/packages/flutter_catalog/lib/src/widgets/catalog_grid_item.dart @@ -0,0 +1,117 @@ +import "package:cached_network_image/cached_network_image.dart"; +import "package:flutter/material.dart"; +import "package:flutter_catalog_interface/flutter_catalog_interface.dart"; + +/// A widget that displays a single catalog item in a grid format. +/// +/// This is used by the [CatalogOverviewView] to show a preview of an item. +class CatalogGridItem extends StatelessWidget { + /// Creates a widget to display a [CatalogItem] in a grid. + const CatalogGridItem({ + required this.item, + required this.author, + super.key, + this.onTap, + }); + + /// The catalog item to display. + final CatalogItem item; + + /// The author of the item, if available. + final CatalogUser? author; + + /// A callback that is executed when the item is tapped. + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + var colorScheme = theme.colorScheme; + var textTheme = theme.textTheme; + + return GestureDetector( + onTap: onTap, + child: Card( + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16.0), + ), + elevation: 0, + color: colorScheme.surface, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (item.imageUrls.isNotEmpty) ...[ + /// The item image, which expands to fill the available space. + Expanded( + child: CachedNetworkImage( + imageUrl: item.imageUrls.first, + fit: BoxFit.cover, + placeholder: (context, url) => + const Center(child: CircularProgressIndicator.adaptive()), + errorWidget: (context, url, error) => + const Icon(Icons.broken_image), + ), + ), + ], + + /// The details section at the bottom of the card. + Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + item.price?.toStringAsFixed(2) ?? "Free", + style: textTheme.bodyMedium, + ), + const SizedBox(height: 4), + Row( + children: [ + Icon( + Icons.person, + size: 14, + color: colorScheme.onSurface.withOpacity(0.7), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + author?.name ?? "Unknown", + style: textTheme.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (item.distanceKM != null) ...[ + const SizedBox(width: 8), + Icon( + Icons.location_on, + size: 14, + color: colorScheme.onSurface.withOpacity(0.7), + ), + const SizedBox(width: 4), + Text( + "${item.distanceKM?.toStringAsFixed(1)} km", + style: textTheme.bodySmall, + ), + ], + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/flutter_catalog/lib/src/widgets/image_view_carousel.dart b/packages/flutter_catalog/lib/src/widgets/image_view_carousel.dart new file mode 100644 index 0000000..8dadaef --- /dev/null +++ b/packages/flutter_catalog/lib/src/widgets/image_view_carousel.dart @@ -0,0 +1,78 @@ +import "package:cached_network_image/cached_network_image.dart"; +import "package:flutter/material.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; + +/// +class ImageCarousel extends HookWidget { + /// Creates an [ImageCarousel] widget. + const ImageCarousel({ + required this.mediaUrls, + super.key, + }); + + /// + final List mediaUrls; + + @override + Widget build(BuildContext context) { + var pageController = usePageController(); + var currentPage = useState(0); + + useEffect( + () { + void listener() { + if (pageController.page?.round() != currentPage.value) { + currentPage.value = pageController.page!.round(); + } + } + + pageController.addListener(listener); + return () => pageController.removeListener(listener); + }, + [pageController], + ); + + return SizedBox( + height: 320, + child: Stack( + alignment: Alignment.bottomCenter, + children: [ + PageView.builder( + controller: pageController, + itemCount: mediaUrls.length, + itemBuilder: (context, index) => CachedNetworkImage( + imageUrl: mediaUrls[index], + fit: BoxFit.cover, + errorWidget: (context, url, error) => const Icon(Icons.error), + placeholder: (context, url) => const Center( + child: CircularProgressIndicator.adaptive(), + ), + ), + ), + if (mediaUrls.length > 1) ...[ + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + mediaUrls.length, + (index) => Container( + width: 8.0, + height: 8.0, + margin: const EdgeInsets.symmetric(horizontal: 4.0), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: currentPage.value == index + ? Colors.white + : Colors.white.withOpacity(0.5), + ), + ), + ), + ), + ), + ], + ], + ), + ); + } +} diff --git a/packages/flutter_catalog/lib/src/widgets/inputs/checkbox_input_section.dart b/packages/flutter_catalog/lib/src/widgets/inputs/checkbox_input_section.dart new file mode 100644 index 0000000..5cfcd52 --- /dev/null +++ b/packages/flutter_catalog/lib/src/widgets/inputs/checkbox_input_section.dart @@ -0,0 +1,83 @@ +import "package:flutter/material.dart"; +import "package:flutter_catalog/src/widgets/inputs/input_section.dart"; + +/// A generic widget that displays a section with a list of selectable +/// checkboxes, arranged in a list or a grid. +class CheckboxInputSection extends StatelessWidget { + /// Creates a [CheckboxInputSection]. + const CheckboxInputSection({ + required this.title, + required this.options, + required this.selectedKeys, + required this.keySelector, + required this.labelSelector, + required this.onOptionToggled, + this.isMultiSelect = true, + this.mandatory = false, + this.gridCrossAxisCount = 1, + super.key, + }); + + /// The title of the section. + final String title; + + /// The list of all available option objects. + final List options; + + /// A list of the keys of the currently selected options. + final List selectedKeys; + + /// A function that returns a unique string key for a given option [T]. + final String Function(T option) keySelector; + + /// A function that returns the display string for a given option [T]. + final String Function(T option) labelSelector; + + /// A callback that is fired when a checkbox is toggled. + /// It returns the key of the toggled option. + final ValueChanged onOptionToggled; + + /// Whether multiple options can be selected. + final bool isMultiSelect; + + /// Whether the section is marked as mandatory. + final bool mandatory; + + /// The number of columns to use if displayed as a grid. + final int gridCrossAxisCount; + @override + Widget build(BuildContext context) => InputSection( + title: title, + mandatory: mandatory, + input: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: gridCrossAxisCount, + childAspectRatio: 4, + crossAxisSpacing: 8, + mainAxisSpacing: 0, + ), + itemCount: options.length, + itemBuilder: (context, index) { + var option = options[index]; + var key = keySelector(option); + var label = labelSelector(option); + var isSelected = selectedKeys.contains(key); + + return InkWell( + onTap: () => onOptionToggled(key), + child: Row( + children: [ + Checkbox( + value: isSelected, + onChanged: (_) => onOptionToggled(key), + ), + Expanded(child: Text(label)), + ], + ), + ); + }, + ), + ); +} diff --git a/packages/flutter_catalog/lib/src/widgets/inputs/image_selection_widget.dart b/packages/flutter_catalog/lib/src/widgets/inputs/image_selection_widget.dart new file mode 100644 index 0000000..e9e3320 --- /dev/null +++ b/packages/flutter_catalog/lib/src/widgets/inputs/image_selection_widget.dart @@ -0,0 +1,237 @@ +// ignore_for_file: discarded_futures + +import "dart:math" as math; + +import "package:cached_network_image/cached_network_image.dart"; +import "package:carousel/carousel.dart"; +import "package:flutter/foundation.dart"; +import "package:flutter/material.dart"; +import "package:flutter_catalog/l10n/app_localizations.dart"; +import "package:flutter_catalog/src/utils/scope.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:image_picker/image_picker.dart"; + +/// +class ImageSelection extends HookWidget { + /// + const ImageSelection({ + required this.existingImageUrls, + required this.newImages, + required this.onNewImagesChanged, + required this.onExistingImagesChanged, + super.key, + }); + + /// The list of existing image URLs. + final List existingImageUrls; + + /// The list of new image files. + final List newImages; + + /// Callback when new images are selected. + final ValueChanged> onNewImagesChanged; + + /// Callback when existing images are changed. + final ValueChanged> onExistingImagesChanged; + + @override + Widget build(BuildContext context) { + var options = CatalogScope.of(context).options; + var picker = ImagePicker(); + var allItems = [...existingImageUrls, ...newImages]; + var localizations = FlutterCatalogLocalizations.of(context)!; + + var currentPage = useState(0); + + Future pickImages() async { + var pickedFiles = await picker.pickMultiImage(); + onNewImagesChanged([...newImages, ...pickedFiles]); + } + + void deleteImage(int index) { + if (index < existingImageUrls.length) { + var updatedUrls = List.from(existingImageUrls)..removeAt(index); + onExistingImagesChanged(updatedUrls); + } else { + var newImageIndex = index - existingImageUrls.length; + var updatedNewImages = List.from(newImages) + ..removeAt(newImageIndex); + onNewImagesChanged(updatedNewImages); + } + } + + Widget buildImageCard(BuildContext context, int index) { + if (index < 0 || index >= allItems.length) { + return const SizedBox.shrink(); + } + + var item = allItems[index]; + + return Stack( + alignment: Alignment.center, + children: [ + Container( + height: 250, + margin: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12.0), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 10, + offset: const Offset(0, 5), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12.0), + child: item is String + ? CachedNetworkImage( + imageUrl: item, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + placeholder: (context, url) => const Center( + child: CircularProgressIndicator.adaptive(), + ), + errorWidget: (context, url, error) => + const Icon(Icons.error), + ) + : _XFileImage(file: item as XFile), + ), + ), + // The delete button is now on every card + Positioned( + top: 0, + left: 0, // Positioned on the left + child: Material( + color: Colors.black54, + // Adjusted border radius for the new corner + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(12), + bottomRight: Radius.circular(12), + ), + child: IconButton( + icon: const Icon(Icons.delete_forever, color: Colors.white), + splashRadius: 20, + onPressed: () => deleteImage(index), + tooltip: "Delete Image", + ), + ), + ), + ], + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (allItems.isEmpty) + _buildImagePlaceholder(onTap: pickImages) + else ...[ + Carousel( + pageViewHeight: 250, + alignment: Alignment.center, + onPageChanged: (index) { + currentPage.value = index; + }, + allowInfiniteScrollingBackwards: false, + selectableCardId: 0, + transforms: [ + CardTransform(x: 0, y: 0, angle: 0, scale: 1, opacity: 1.0), + CardTransform( + x: 110, + y: -60, + angle: math.pi / 12, + scale: 0.7, + opacity: 0.8, + ), + CardTransform( + x: 200, + y: -75, + angle: -math.pi / 12, + scale: 0.6, + opacity: 0.6, + ), + CardTransform( + x: 230, + y: -80, + angle: math.pi / 12, + scale: 0.5, + opacity: 0.4, + ), + CardTransform( + x: 220, + y: -85, + angle: -math.pi / 12, + scale: 0.3, + opacity: 0.2, + ), + ], + builder: buildImageCard, + ), + ], + const SizedBox(height: 16), + options.builders.primaryButtonBuilder( + context, + onPressed: pickImages, + onDisabledPressed: () {}, + isDisabled: false, + child: Text(localizations.itemCreatePageAddImagesButton), + ), + ], + ); + } + + Widget _buildImagePlaceholder({required VoidCallback onTap}) => + GestureDetector( + onTap: onTap, + child: Container( + height: 250, + width: double.infinity, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12.0), + border: Border.all( + color: Colors.grey.shade400, + width: 2.0, + style: BorderStyle.solid, + ), + ), + child: Center( + child: Icon( + Icons.add_photo_alternate_outlined, + color: Colors.grey.shade600, + size: 60, + ), + ), + ), + ); +} + +/// A helper widget to display an XFile image without flickering. +/// It is now a HookWidget to memoize the future. +class _XFileImage extends HookWidget { + const _XFileImage({required this.file}); + final XFile file; + + @override + Widget build(BuildContext context) { + Widget image; + if (kIsWeb) { + image = Image.network(file.path, fit: BoxFit.cover); + } else { + var imageFuture = useMemoized(file.readAsBytes, [file.path]); + var snapshot = useFuture(imageFuture); + + if (snapshot.hasData) { + image = Image.memory(snapshot.data!, fit: BoxFit.cover); + } else { + image = const Center(child: CircularProgressIndicator.adaptive()); + } + } + + return SizedBox.expand(child: image); + } +} diff --git a/packages/flutter_catalog/lib/src/widgets/inputs/input_section.dart b/packages/flutter_catalog/lib/src/widgets/inputs/input_section.dart new file mode 100644 index 0000000..51e3b94 --- /dev/null +++ b/packages/flutter_catalog/lib/src/widgets/inputs/input_section.dart @@ -0,0 +1,93 @@ +import "package:flutter/material.dart"; +import "package:flutter_catalog/l10n/app_localizations.dart"; +import "package:flutter_catalog/src/views/catalog_modify_view.dart"; + +/// A section with a title and an optional input widget. +class InputSection extends StatelessWidget { + /// Creates a section with a title and an optional input widget. + const InputSection({ + required this.title, + this.input, + this.onTap, + this.mandatory = false, + super.key, + }); + + /// The title of the section. + final String title; + + /// Whether the section is mandatory. + final bool mandatory; + + /// The input widget to display in the section. + final Widget? input; + + /// Callback function to be called when the section is tapped. + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + var textTheme = theme.textTheme; + var localizations = FlutterCatalogLocalizations.of(context)!; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: theme.colorScheme.outline, + width: 1.0, + style: BorderStyle.solid, + ), + ), + color: const Color(0xFFC3E4EB), + ), + padding: const EdgeInsets.symmetric( + horizontal: itemModificationScreenSidePadding, + vertical: 12, + ).copyWith( + right: 12, + ), + child: Row( + children: [ + Text( + title, + style: textTheme.titleSmall, + ), + const SizedBox(width: 4), + if (mandatory) ...[ + Text( + "(${localizations.itemCreatePageMandatorySection})", + style: textTheme.labelSmall, + ), + ], + if (onTap != null) ...[ + const Spacer(), + IconButton( + icon: const Icon(Icons.chevron_right), + splashRadius: 16, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + iconSize: 24, + onPressed: onTap, + ), + ], + ], + ), + ), + if (input != null) ...[ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: itemModificationScreenSidePadding, + vertical: 24, + ), + child: input, + ), + ], + ], + ); + } +} diff --git a/packages/flutter_catalog/lib/src/widgets/inputs/range_slider_input_section.dart b/packages/flutter_catalog/lib/src/widgets/inputs/range_slider_input_section.dart new file mode 100644 index 0000000..7738670 --- /dev/null +++ b/packages/flutter_catalog/lib/src/widgets/inputs/range_slider_input_section.dart @@ -0,0 +1,63 @@ +import "package:flutter/material.dart"; + +/// A self-contained widget that displays a section with a RangeSlider, +/// complete with its own title and background. +class RangeSliderInputSection extends StatelessWidget { + /// Creates a [RangeSliderInputSection]. + const RangeSliderInputSection({ + required this.title, + required this.min, + required this.max, + required this.values, + required this.onChanged, + super.key, + }); + + /// The title of the section. + final String title; + + /// The minimum and maximum values for the RangeSlider. + final int min; + + /// The maximum value for the RangeSlider. + final int max; + + /// The current values of the RangeSlider. + final RangeValues values; + + /// A callback that is called when the RangeSlider values change. + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + + return Card( + elevation: 0, + color: theme.colorScheme.surfaceContainer, + clipBehavior: Clip.antiAlias, + margin: const EdgeInsets.only(bottom: 16), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.titleLarge), + const SizedBox(height: 8), + RangeSlider( + values: values, + min: min.toDouble(), + max: max.toDouble(), + divisions: max - min, + labels: RangeLabels( + values.start.round().toString(), + values.end.round().toString(), + ), + onChanged: onChanged, + ), + ], + ), + ), + ); + } +} diff --git a/packages/flutter_catalog/lib/src/widgets/inputs/text_input_section.dart b/packages/flutter_catalog/lib/src/widgets/inputs/text_input_section.dart new file mode 100644 index 0000000..8e5bb3a --- /dev/null +++ b/packages/flutter_catalog/lib/src/widgets/inputs/text_input_section.dart @@ -0,0 +1,49 @@ +import "package:flutter/material.dart"; +import "package:flutter_catalog/src/widgets/inputs/input_section.dart"; + +/// +class TextInputSection extends StatelessWidget { + /// + const TextInputSection({ + required this.title, + required this.label, + this.value, + this.onChanged, + this.onTap, + this.mandatory = false, + super.key, + }); + + /// + final String title; + + /// + final String label; + + /// + final String? value; + + /// + final ValueChanged? onChanged; + + /// + final VoidCallback? onTap; + + /// + final bool mandatory; + + @override + Widget build(BuildContext context) => InputSection( + title: title, + onTap: onTap, + mandatory: mandatory, + input: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: TextFormField( + decoration: InputDecoration(labelText: label), + initialValue: value, + onChanged: onChanged, + ), + ), + ); +} diff --git a/packages/flutter_catalog/pubspec.yaml b/packages/flutter_catalog/pubspec.yaml new file mode 100644 index 0000000..b139809 --- /dev/null +++ b/packages/flutter_catalog/pubspec.yaml @@ -0,0 +1,50 @@ +name: flutter_catalog +description: A catalog implementation of Flutter Feed. +version: 0.1.0 +homepage: https://github.com/Iconica-Development/flutter_feed +publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub + +environment: + sdk: ">=3.4.0 <4.0.0" + flutter: ">=3.22.0" + +dependencies: + # Core + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + intl: any + + # Utilities + cached_network_image: ^3.3.0 + flutter_hooks: ">=0.18.0 <1.0.0" + cross_file: ^0.3.0 + image_picker: ^1.1.0 + + # Internal packages + flutter_catalog_interface: + hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub + version: ^0.1.0 + dart_feed_utilities: + hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub + version: ^0.1.0 + flutter_feed_utils: + hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub + version: ^0.1.0 + + # External iconica packages + carousel: + hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub + version: ^0.3.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_iconica_analysis: + hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub + version: ^7.0.0 + +flutter: + uses-material-design: true + generate: true diff --git a/packages/flutter_catalog_interface/analysis_options.yaml b/packages/flutter_catalog_interface/analysis_options.yaml new file mode 100644 index 0000000..42aad11 --- /dev/null +++ b/packages/flutter_catalog_interface/analysis_options.yaml @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2025 Iconica +# +# SPDX-License-Identifier: GPL-3.0-or-later + +include: package:flutter_iconica_analysis/components_options.yaml + +analyzer: + errors: + avoid_equals_and_hash_code_on_mutable_classes: ignore + +linter: + rules: diff --git a/packages/flutter_catalog_interface/lib/flutter_catalog_interface.dart b/packages/flutter_catalog_interface/lib/flutter_catalog_interface.dart new file mode 100644 index 0000000..f9aa5aa --- /dev/null +++ b/packages/flutter_catalog_interface/lib/flutter_catalog_interface.dart @@ -0,0 +1,10 @@ +/// A library that defines the interfaces for the Flutter Feed Catalog feature. +/// +/// This includes data models and repository contracts. +library flutter_catalog_interface; + +export "src/models/catalog_item.dart"; +export "src/models/catalog_user.dart"; +export "src/models/lat_lng.dart"; +export "src/repositories/catalog_repository.dart"; +export "src/repositories/catalog_user_repository.dart"; diff --git a/packages/flutter_catalog_interface/lib/src/models/catalog_item.dart b/packages/flutter_catalog_interface/lib/src/models/catalog_item.dart new file mode 100644 index 0000000..7661d63 --- /dev/null +++ b/packages/flutter_catalog_interface/lib/src/models/catalog_item.dart @@ -0,0 +1,134 @@ +import "package:flutter_catalog_interface/src/models/lat_lng.dart"; + +/// Represents the base data for an item in the catalog. +/// It contains fields that are common across different applications. +class CatalogItem { + /// Creates a new [CatalogItem] instance. + const CatalogItem({ + required this.id, + required this.title, + required this.description, + required this.imageUrls, + required this.location, + required this.postedAt, + this.price, + this.authorId, + this.customFields = const {}, + this.distanceKM, + this.isFavorited, + }); + + /// Creates a [CatalogItem] from a JSON map. + factory CatalogItem.fromJson(Map json) { + var knownKeys = [ + "id", + "title", + "description", + "imageUrls", + "location", + "postedAt", + "price", + "authorId", + "distanceKM", + "isFavorited", + ]; + var customFields = Map.from(json) + ..removeWhere((key, value) => knownKeys.contains(key)); + return CatalogItem( + id: json["id"] as String, + title: json["title"] as String, + description: json["description"] as String, + imageUrls: List.from(json["imageUrls"] as List? ?? []), + location: LatLng.fromJson(json["location"] as Map), + postedAt: DateTime.parse(json["postedAt"] as String), + price: (json["price"] as num?)?.toDouble(), + authorId: json["authorId"] as String?, + isFavorited: json["isFavorited"] as bool?, + distanceKM: (json["distanceKM"] as num?)?.toDouble(), + customFields: customFields, + ); + } + + /// The unique identifier for the catalog item. + final String id; + + /// The title of the catalog item. + final String title; + + /// A detailed description of the catalog item. + final String description; + + /// A list of URLs pointing to images of the catalog item. + final List imageUrls; + + /// The geographical location of the catalog item. + final LatLng location; + + /// The date and time when the catalog item was posted. + final DateTime postedAt; + + /// The price of the catalog item. + /// This field is optional and can be null. + final double? price; + + /// The ID of the user who created or owns the catalog item. + final String? authorId; + + /// Custom fields for the catalog item, allowing for additional metadata. + final Map customFields; + + /// The distance in kilometers from the user's location to the catalog item. + final double? distanceKM; + + /// Indicates whether the catalog item is favorited by the user. + final bool? isFavorited; + + /// Converts this [CatalogItem] to a JSON representation. + Map toJson() => { + "id": id, + "title": title, + "description": description, + "imageUrls": imageUrls, + "location": location.toJson(), + "postedAt": postedAt.toIso8601String(), + if (price != null) "price": price, + if (authorId != null) "authorId": authorId, + ...customFields, + }; + + //... copyWith, hashCode, and operator== methods would also be updated + @override + String toString() => "CatalogItem(id: $id, title: $title, " + "description: $description, imageUrls: $imageUrls, location: " + "$location, postedAt: $postedAt, price: $price, authorId: " + "$authorId, distanceKM: " + "$distanceKM, isFavorited: $isFavorited)"; + + /// Creates a copy of this [CatalogItem] with the given fields replaced. + CatalogItem copyWith({ + String? id, + String? title, + String? description, + List? imageUrls, + LatLng? location, + DateTime? postedAt, + double? price, + String? authorId, + double? distanceKM, + bool? isFavorited, + Map? customFields, + }) => + CatalogItem( + id: id ?? this.id, + title: title ?? this.title, + description: description ?? this.description, + imageUrls: imageUrls ?? this.imageUrls, + location: location ?? this.location, + postedAt: postedAt ?? this.postedAt, + price: price ?? this.price, + authorId: authorId ?? this.authorId, + distanceKM: distanceKM ?? this.distanceKM, + isFavorited: isFavorited ?? this.isFavorited, + customFields: customFields ?? this.customFields, + ); +} diff --git a/packages/flutter_catalog_interface/lib/src/models/catalog_user.dart b/packages/flutter_catalog_interface/lib/src/models/catalog_user.dart new file mode 100644 index 0000000..03447ce --- /dev/null +++ b/packages/flutter_catalog_interface/lib/src/models/catalog_user.dart @@ -0,0 +1,40 @@ +// ignore_for_file: public_member_api_docs + +/// Represents a user in the catalog system. +class CatalogUser { + const CatalogUser({ + required this.id, + required this.name, + this.avatarUrl, + }); + + /// Creates a [CatalogUser] from a JSON map. + factory CatalogUser.fromJson(Map json) => CatalogUser( + id: json["id"] as String, + name: json["username"] as String, + avatarUrl: json["avatar_url"] as String?, + ); + + final String id; + final String name; + final String? avatarUrl; + + /// Converts this object to a JSON map. + Map toJson() => { + "id": id, + "username": name, + "avatar_url": avatarUrl, + }; + + /// Creates a copy of this user with the given fields replaced. + CatalogUser copyWith({ + String? id, + String? name, + String? avatarUrl, + }) => + CatalogUser( + id: id ?? this.id, + name: name ?? this.name, + avatarUrl: avatarUrl ?? this.avatarUrl, + ); +} diff --git a/packages/flutter_catalog_interface/lib/src/models/lat_lng.dart b/packages/flutter_catalog_interface/lib/src/models/lat_lng.dart new file mode 100644 index 0000000..5ee73ef --- /dev/null +++ b/packages/flutter_catalog_interface/lib/src/models/lat_lng.dart @@ -0,0 +1,41 @@ +/// Represents a geographical point with latitude and longitude. +class LatLng { + /// Creates a [LatLng] instance. + const LatLng({ + required this.latitude, + required this.longitude, + }); + + /// Creates a [LatLng] from a JSON map. + factory LatLng.fromJson(Map json) => LatLng( + latitude: (json["latitude"] as num).toDouble(), + longitude: (json["longitude"] as num).toDouble(), + ); + + /// The latitude of the geographical point. + final double latitude; + + /// The longitude of the geographical point. + final double longitude; + + /// Converts this [LatLng] instance to a JSON map. + Map toJson() => { + "latitude": latitude, + "longitude": longitude, + }; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is LatLng && + other.latitude == latitude && + other.longitude == longitude; + } + + @override + int get hashCode => latitude.hashCode ^ longitude.hashCode; + + @override + String toString() => "LatLng(latitude: $latitude, longitude: $longitude)"; +} diff --git a/packages/flutter_catalog_interface/lib/src/repositories/catalog_repository.dart b/packages/flutter_catalog_interface/lib/src/repositories/catalog_repository.dart new file mode 100644 index 0000000..46aed8e --- /dev/null +++ b/packages/flutter_catalog_interface/lib/src/repositories/catalog_repository.dart @@ -0,0 +1,50 @@ +import "package:cross_file/cross_file.dart"; +import "package:flutter_catalog_interface/src/models/catalog_item.dart"; +import "package:flutter_catalog_interface/src/models/lat_lng.dart"; + +export "package:cross_file/cross_file.dart" show XFile; + +/// The interface for interacting with the catalog. +abstract class CatalogRepository { + /// Creates a new catalog item from a map. + Future createCatalogItem(Map item); + + /// Uploads an image from an [XFile] object and returns its public URL. + Future uploadImage(XFile imageFile); + + /// Updates an existing catalog item. + Future updateCatalogItem(String itemId, Map item); + + /// Deletes a catalog item by its ID. + Future deleteCatalogItem(String itemId); + + /// Fetches a list of catalog items. + /// + /// [userId] is required for personalized results or distance calculations. + /// [userLocation] provides the GPS location for distance calculation. + /// [filters] allow for filtering the items (e.g., by category). + /// [limit] specifies the maximum number of items to return. + /// [offset] specifies the starting index for pagination. + Future> fetchCatalogItems({ + required String userId, + LatLng? userLocation, + Map? filters, + int? limit, + int? offset, + }); + + /// Fetches a single catalog item by its ID. + /// + /// [itemId] is the unique identifier of the catalog item. + /// [userId] is the ID of the user requesting the item, used for personalized + Future fetchCatalogItemById( + String itemId, + String userId, + ); + + /// Toggles the favorite status of a catalog item for a specific user. + /// + /// [itemId] is the ID of the catalog item. + /// [userId] is the ID of the user performing the action. + Future toggleFavorite(String itemId, String userId); +} diff --git a/packages/flutter_catalog_interface/lib/src/repositories/catalog_user_repository.dart b/packages/flutter_catalog_interface/lib/src/repositories/catalog_user_repository.dart new file mode 100644 index 0000000..c06b336 --- /dev/null +++ b/packages/flutter_catalog_interface/lib/src/repositories/catalog_user_repository.dart @@ -0,0 +1,12 @@ +// ignore_for_file: one_member_abstracts + +import "package:flutter_catalog_interface/src/models/catalog_user.dart"; + +/// The interface for fetching user data. +abstract class CatalogUserRepository { + /// Fetches a single user by their ID. + Future getUser(String userId); + + /// Fetches a list of users by their IDs. + Future> getUsers(List userIds); +} diff --git a/packages/flutter_catalog_interface/pubspec.yaml b/packages/flutter_catalog_interface/pubspec.yaml new file mode 100644 index 0000000..9f09130 --- /dev/null +++ b/packages/flutter_catalog_interface/pubspec.yaml @@ -0,0 +1,16 @@ +name: flutter_catalog_interface +description: Service interfaces of the Flutter Feed Catalog repository +version: 0.1.0 +homepage: https://github.com/Iconica-Development/flutter_feed +publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub + +environment: + sdk: ">=3.4.0 <4.0.0" + +dependencies: + cross_file: ^0.3.0 + +dev_dependencies: + flutter_iconica_analysis: + hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub + version: ^7.0.0 diff --git a/packages/flutter_catalog_rest_api/analysis_options.yaml b/packages/flutter_catalog_rest_api/analysis_options.yaml new file mode 100644 index 0000000..1df146b --- /dev/null +++ b/packages/flutter_catalog_rest_api/analysis_options.yaml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2025 Iconica +# +# SPDX-License-Identifier: GPL-3.0-or-later + +include: package:flutter_iconica_analysis/components_options.yaml + +analyzer: + +linter: + rules: diff --git a/packages/flutter_catalog_rest_api/lib/flutter_catalog_rest_api.dart b/packages/flutter_catalog_rest_api/lib/flutter_catalog_rest_api.dart new file mode 100644 index 0000000..ee2226f --- /dev/null +++ b/packages/flutter_catalog_rest_api/lib/flutter_catalog_rest_api.dart @@ -0,0 +1,5 @@ +/// +library flutter_catalog_rest_api; + +export "src/rest_catalog_repository.dart"; +export "src/rest_catalog_user_repository.dart"; diff --git a/packages/flutter_catalog_rest_api/lib/src/rest_catalog_repository.dart b/packages/flutter_catalog_rest_api/lib/src/rest_catalog_repository.dart new file mode 100644 index 0000000..33de229 --- /dev/null +++ b/packages/flutter_catalog_rest_api/lib/src/rest_catalog_repository.dart @@ -0,0 +1,266 @@ +import "dart:convert"; + +import "package:dart_api_service/dart_api_service.dart" as http; +import "package:dart_api_service/dart_api_service.dart"; +import "package:flutter_catalog_interface/flutter_catalog_interface.dart"; +import "package:flutter_catalog_rest_api/src/rest_converters.dart"; + +export "package:dart_api_service/dart_api_service.dart" show Client; + +/// A generic implementation of [CatalogRepository] that uses a RESTful API. +/// +/// This repository is generic over `T` which must be a type that extends +/// [CatalogItem]. This allows projects to use their own custom item models +/// while still leveraging this reusable repository. +/// +/// For simple use cases with the base [CatalogItem], the `fromJsonFactory` +/// is not required. For custom subclasses of [CatalogItem], the factory +/// must be provided to ensure correct JSON deserialization. +class RestCatalogRepository + extends HttpApiService> implements CatalogRepository { + /// Creates an instance of the [RestCatalogRepository]. + /// + /// Requires a [baseUrl] for the API and can be configured with an optional + /// [fromJsonFactory] for custom `CatalogItem` types. + RestCatalogRepository({ + required super.baseUrl, + T Function(Map)? fromJsonFactory, + super.authenticationService, + super.client, + super.defaultHeaders, + this.apiPrefix = "", + this.fetchCatalogItemsEndpoint = "/catalog/catalog-items", + this.fetchCatalogItemByIdEndpoint = "/catalog/catalog-items/:id", + this.toggleFavoriteEndpoint = "/catalog/catalog-items/:itemId/favorite", + this.createCatalogItemEndpoint = "/catalog/catalog-items", + this.uploadImageEndpoint = "/catalog/upload-image", + this.updateCatalogItemEndpoint = "/catalog/catalog-items/:id", + this.deleteCatalogItemEndpoint = "/catalog/catalog-items/:id", + }) : fromJsonFactory = _getFromJsonFactory(fromJsonFactory), + super( + apiResponseConverter: createCatalogItemsConverter( + fromJson: _getFromJsonFactory(fromJsonFactory), + ), + ); + + /// The factory function used to create an instance of `T` from a JSON map. + final T Function(Map) fromJsonFactory; + + /// The common prefix for all API endpoints in this repository. + final String apiPrefix; + + /// The endpoint for fetching a list of catalog items. + final String fetchCatalogItemsEndpoint; + + /// The endpoint for fetching a single catalog item by its ID. + final String fetchCatalogItemByIdEndpoint; + + /// The endpoint for toggling the favorite status of an item. + final String toggleFavoriteEndpoint; + + /// The endpoint for creating a new catalog item. + final String createCatalogItemEndpoint; + + /// The endpoint for uploading an image. + final String uploadImageEndpoint; + + /// The endpoint for updating an existing catalog item. + final String updateCatalogItemEndpoint; + + /// The endpoint for deleting a catalog item. + final String deleteCatalogItemEndpoint; + + /// A helper that provides a default `fromJson` factory for the base + /// [CatalogItem] or throws an error if a factory is missing for a custom + /// type. + static U Function(Map) + _getFromJsonFactory( + U Function(Map)? fromJson, + ) { + if (fromJson != null) { + return fromJson; + } + if (U == CatalogItem) { + return CatalogItem.fromJson as U Function(Map); + } + throw ArgumentError( + "A fromJsonFactory must be provided for custom types like $U.", + "fromJsonFactory", + ); + } + + /// Returns a base [Endpoint] instance with the configured API prefix. + Endpoint, List> get _baseEndpoint => endpoint(apiPrefix); + + @override + Future createCatalogItem(Map item) async { + var createEndpoint = _baseEndpoint + .child(createCatalogItemEndpoint) + .authenticate() + .withConverter(const NoOpConverter()); + + try { + await createEndpoint.post(requestModel: item); + } on ApiException { + rethrow; + } on Exception catch (e, s) { + throw ApiException( + inner: http.Response("Unexpected error: $e", 500), + error: e, + stackTrace: s, + ); + } + } + + @override + Future uploadImage(XFile imageFile) async { + var uploadEndpoint = _baseEndpoint + .child(uploadImageEndpoint) + .authenticate() + .withConverter(const JsonMapResponseConverter()); + + try { + var response = await uploadEndpoint.upload( + fieldName: "image", + fileBytes: await imageFile.readAsBytes(), + fileName: imageFile.name, + ); + + var url = response.result?["url"] as String?; + + if (url == null) { + throw ApiException( + inner: response.inner, + error: "URL not found in upload response", + ); + } + return url; + } on ApiException { + rethrow; + } on Exception catch (e, s) { + throw ApiException( + inner: http.Response("Unexpected error: $e", 500), + error: e, + stackTrace: s, + ); + } + } + + @override + Future updateCatalogItem( + String itemId, + Map item, + ) async { + var updateEndpoint = _baseEndpoint + .child(updateCatalogItemEndpoint) + .authenticate() + .withVariables({"id": itemId}).withConverter(const NoOpConverter()); + + try { + await updateEndpoint.patch(requestModel: item); + } on ApiException { + rethrow; + } on Exception catch (_) {} + } + + @override + Future deleteCatalogItem(String itemId) async { + var deleteEndpoint = _baseEndpoint + .child(deleteCatalogItemEndpoint) + .authenticate() + .withVariables({"id": itemId}); + + try { + await deleteEndpoint.delete(); + } on ApiException { + rethrow; + } on Exception catch (_) {} + } + + @override + Future> fetchCatalogItems({ + required String userId, + LatLng? userLocation, + Map? filters, + int? limit, + int? offset, + }) async { + var catalogEndpoint = + _baseEndpoint.child(fetchCatalogItemsEndpoint).authenticate(); + + var queryParameters = {"userId": userId}; + if (userLocation != null) { + queryParameters["latitude"] = userLocation.latitude.toString(); + queryParameters["longitude"] = userLocation.longitude.toString(); + } + if (filters != null) { + queryParameters["filters"] = jsonEncode(filters); + } + if (limit != null) { + queryParameters["limit"] = limit.toString(); + } + if (offset != null) { + queryParameters["offset"] = offset.toString(); + } + + try { + var response = + await catalogEndpoint.get(queryParameters: queryParameters); + return response.result ?? []; + } on ApiException { + rethrow; + } on Exception catch (e, s) { + throw ApiException( + inner: http.Response("Unexpected error: $e", 500), + error: e, + stackTrace: s, + ); + } + } + + @override + Future fetchCatalogItemById(String id, String userId) async { + var itemEndpoint = _baseEndpoint + .child(fetchCatalogItemByIdEndpoint) + .authenticate() + .withVariables({"id": id}).withConverter( + createCatalogItemConverter(fromJson: fromJsonFactory), + ); + + try { + var response = await itemEndpoint.get(); + return response.result; + } on ApiException catch (e) { + if (e.statusCode == 404) { + return null; + } + rethrow; + } on Exception catch (e, s) { + throw ApiException( + inner: http.Response("Unexpected error: $e", 500), + error: e, + stackTrace: s, + ); + } + } + + @override + Future toggleFavorite(String itemId, String userId) async { + var favoriteEndpoint = _baseEndpoint + .child(toggleFavoriteEndpoint) + .authenticate() + .withVariables({"itemId": itemId}).withConverter(const NoOpConverter()); + + try { + await favoriteEndpoint.post(requestModel: {"userId": userId}); + } on ApiException { + rethrow; + } on Exception catch (e, s) { + throw ApiException( + inner: http.Response("Unexpected error: $e", 500), + error: e, + stackTrace: s, + ); + } + } +} diff --git a/packages/flutter_catalog_rest_api/lib/src/rest_catalog_user_repository.dart b/packages/flutter_catalog_rest_api/lib/src/rest_catalog_user_repository.dart new file mode 100644 index 0000000..ce47298 --- /dev/null +++ b/packages/flutter_catalog_rest_api/lib/src/rest_catalog_user_repository.dart @@ -0,0 +1,85 @@ +import "package:dart_api_service/dart_api_service.dart" as http; +import "package:dart_api_service/dart_api_service.dart"; +import "package:flutter_catalog_interface/flutter_catalog_interface.dart"; + +/// An implementation of [CatalogUserRepository] that uses a RESTful API. +/// +/// This repository is generic over `T` which must extend [CatalogUser], +/// allowing projects to use their own custom user models. +class RestCatalogUserRepository extends HttpApiService + implements CatalogUserRepository { + /// Creates an instance of the [RestCatalogUserRepository]. + RestCatalogUserRepository({ + required super.baseUrl, + required this.fromJsonFactory, + super.authenticationService, + super.client, + super.defaultHeaders, + this.apiPrefix = "", + this.getUsersEndpoint = "/users", + this.getUserEndpoint = "/users/:id", + }) : super( + // The converter now uses the provided factory. + apiResponseConverter: ModelJsonResponseConverter( + deserialize: fromJsonFactory, + serialize: (user) => user.toJson(), + ), + ); + + /// The factory function to create an instance of T from a JSON map. + final T Function(Map) fromJsonFactory; + + /// The common prefix for all API endpoints in this repository. + final String apiPrefix; + + /// The endpoint for fetching a list of users. + final String getUsersEndpoint; + + /// The endpoint for fetching a user by their ID. + final String getUserEndpoint; + + @override + Future getUser(String userId) async { + var userEndpoint = endpoint(apiPrefix) + .child(getUserEndpoint) + .authenticate() + .withVariables({"id": userId}); + + try { + var response = await userEndpoint.get(); + return response.result; + } on ApiException catch (e) { + if (e.statusCode == 404) { + return null; + } + rethrow; + } on Exception catch (e, s) { + throw ApiException( + inner: http.Response("Unexpected error: $e", 500), + error: e, + stackTrace: s, + ); + } + } + + @override + Future> getUsers(List userIds) async { + var listConverter = ModelListJsonResponseConverter( + deserialize: fromJsonFactory, + serialize: (user) => user.toJson(), + ); + + var usersEndpoint = endpoint(apiPrefix) + .child(getUsersEndpoint) + .authenticate() + .withConverter(listConverter); + try { + var response = await usersEndpoint.get( + queryParameters: {"ids": userIds.join(",")}, + ); + return response.result ?? []; + } on ApiException { + rethrow; + } + } +} diff --git a/packages/flutter_catalog_rest_api/lib/src/rest_converters.dart b/packages/flutter_catalog_rest_api/lib/src/rest_converters.dart new file mode 100644 index 0000000..0adc580 --- /dev/null +++ b/packages/flutter_catalog_rest_api/lib/src/rest_converters.dart @@ -0,0 +1,56 @@ +import "dart:convert"; + +import "package:dart_api_service/dart_api_service.dart"; +import "package:flutter_catalog_interface/flutter_catalog_interface.dart"; + +/// Creates an [ApiConverter] for a list of items that extend [CatalogItem]. +/// +/// This function is used to dynamically generate a converter for any given +/// type `T` that is a subclass of [CatalogItem]. It requires a `fromJson` +/// factory function to be passed in, which it uses to deserialize each +/// object in the JSON list. +ApiConverter, List> + createCatalogItemsConverter({ + required T Function(Map) fromJson, +}) => + ModelListJsonResponseConverter( + deserialize: fromJson, + serialize: (item) => item.toJson(), + ); + +/// Creates an [ApiConverter] for a single item that extends [CatalogItem]. +/// +/// Similar to [createCatalogItemsConverter], but it handles single JSON objects +/// instead of lists. It's used for endpoints that return a single entity, +/// like fetching an item by its ID. +ApiConverter createCatalogItemConverter({ + required T Function(Map) fromJson, +}) => + ModelJsonResponseConverter( + deserialize: fromJson, + serialize: (item) => item.toJson(), + ); + +/// An [ApiConverter] for API calls where the response body is not parsed, +/// but a request body needs to be sent as a JSON object. +/// +/// The `toRepresentation` method is a no-op, returning `void` because +/// the response is ignored. The `fromRepresentation` method takes a +/// `Map` and encodes it into a JSON string for the request +/// body. +/// +/// This is useful for `POST`, `PUT`, or `DELETE` requests that might return a +/// status code like 204 (No Content) with an empty response body. +class NoOpConverter implements ApiConverter> { + /// Creates an instance of [NoOpConverter]. + const NoOpConverter(); + + /// Ignores the response body. + @override + void toRepresentation(Object? input) {} + + /// Encodes the outgoing [representation] map into a JSON string. + @override + Object fromRepresentation(Map representation) => + jsonEncode(representation); +} diff --git a/packages/flutter_catalog_rest_api/pubspec.yaml b/packages/flutter_catalog_rest_api/pubspec.yaml new file mode 100644 index 0000000..7fdbe57 --- /dev/null +++ b/packages/flutter_catalog_rest_api/pubspec.yaml @@ -0,0 +1,21 @@ +name: flutter_catalog_rest_api +description: A RESTful API implementation of the Flutter Feed Catalog repository. +version: 0.1.0 +homepage: https://github.com/Iconica-Development/flutter_feed +publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub + +environment: + sdk: ">=3.4.0 <4.0.0" + +dependencies: + dart_api_service: + hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub/ + version: ^1.1.2 + flutter_catalog_interface: + hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub + version: ^0.1.0 + +dev_dependencies: + flutter_iconica_analysis: + hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub + version: ^7.0.0 diff --git a/packages/flutter_catalog_riverpod/analysis_options.yaml b/packages/flutter_catalog_riverpod/analysis_options.yaml new file mode 100644 index 0000000..db27e5e --- /dev/null +++ b/packages/flutter_catalog_riverpod/analysis_options.yaml @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2025 Iconica +# +# SPDX-License-Identifier: GPL-3.0-or-later + +include: package:flutter_iconica_analysis/components_options.yaml + +linter: + rules: diff --git a/packages/flutter_catalog_riverpod/lib/flutter_catalog_riverpod.dart b/packages/flutter_catalog_riverpod/lib/flutter_catalog_riverpod.dart new file mode 100644 index 0000000..0b7db66 --- /dev/null +++ b/packages/flutter_catalog_riverpod/lib/flutter_catalog_riverpod.dart @@ -0,0 +1,7 @@ +/// A library for caching catalog data using Riverpod. +/// This library provides a caching layer for the Flutter Feed Catalog feature, +library flutter_catalog_riverpod; + +export "src/cache_provider.dart"; +export "src/cached_catalog_item_repository.dart"; +export "src/cached_catalog_user_repository.dart"; diff --git a/packages/flutter_catalog_riverpod/lib/src/cache.dart b/packages/flutter_catalog_riverpod/lib/src/cache.dart new file mode 100644 index 0000000..4feb1fa --- /dev/null +++ b/packages/flutter_catalog_riverpod/lib/src/cache.dart @@ -0,0 +1,64 @@ +import "package:flutter_catalog_interface/flutter_catalog_interface.dart"; + +/// A generic wrapper to hold a cached item and its timestamp. +/// +/// This class is used to store any type of data [T] along with the +/// [DateTime] it was added to the cache, allowing for time-based +/// cache validation. +class CacheEntry { + /// Creates a [CacheEntry]. + const CacheEntry({ + required this.data, + required this.cachedAt, + }); + + /// The cached data itself. + final T data; + + /// The [DateTime] when the data was added to the cache. + final DateTime cachedAt; + + /// Checks if the cache entry is still valid by comparing its age + /// against a provided [maxAge] duration. + /// + /// Returns `true` if the entry is younger than the [maxAge], + /// and `false` otherwise. + bool isValid(Duration maxAge) => DateTime.now().difference(cachedAt) < maxAge; +} + +/// A state object that holds all the cached data for the catalog feature. +/// +/// This class acts as a centralized in-memory database for users, items, +/// and the results of specific item queries. +class CatalogCache { + /// Creates a [CatalogCache], optionally with initial data. + const CatalogCache({ + this.users = const {}, + this.items = const {}, + this.itemQueries = const {}, + }); + + /// A map of cached users, where the key is the user's ID. + final Map> users; + + /// A map of cached catalog items, where the key is the item's ID. + final Map> items; + + /// A map of cached query results. + /// + /// The key is a unique string representing the query's parameters, + /// and the value is a list of the item IDs that were returned. + final Map>> itemQueries; + + /// Creates a new instance of [CatalogCache] with updated values. + CatalogCache copyWith({ + Map>? users, + Map>? items, + Map>>? itemQueries, + }) => + CatalogCache( + users: users ?? this.users, + items: items ?? this.items, + itemQueries: itemQueries ?? this.itemQueries, + ); +} diff --git a/packages/flutter_catalog_riverpod/lib/src/cache_provider.dart b/packages/flutter_catalog_riverpod/lib/src/cache_provider.dart new file mode 100644 index 0000000..5ff2b59 --- /dev/null +++ b/packages/flutter_catalog_riverpod/lib/src/cache_provider.dart @@ -0,0 +1,69 @@ +import "package:flutter_catalog_interface/flutter_catalog_interface.dart"; +import "package:flutter_catalog_riverpod/src/cache.dart"; +import "package:riverpod/riverpod.dart"; + +/// A Riverpod notifier to manage the in-memory cache for the catalog. +/// +/// This class holds the state for cached users and items and provides +/// methods to add new data to the cache or clear it entirely. +class CatalogCacheNotifier extends StateNotifier { + /// Creates a [CatalogCacheNotifier] with an initial empty cache. + CatalogCacheNotifier() : super(const CatalogCache()); + + /// Adds a list of [CatalogUser] objects to the cache. + /// + /// If a user with the same ID already exists in the cache, it will be + /// overwritten with the new data. + void addUsers(List users) { + state = state.copyWith( + users: { + ...state.users, + for (var user in users) + user.id: CacheEntry(data: user, cachedAt: DateTime.now()), + }, + ); + } + + /// Adds a list of [CatalogItem] objects to the cache. + /// + /// If an item with the same ID already exists, it will be overwritten. + void addItems(List items) { + state = state.copyWith( + items: { + ...state.items, + for (var item in items) + item.id: CacheEntry(data: item, cachedAt: DateTime.now()), + }, + ); + } + + /// Caches the result of an item query. + /// + /// The [queryKey] should be a unique identifier representing the specific + /// query parameters, and [itemIds] is the list of item IDs that were + /// returned for that query. + void addItemQuery(String queryKey, List itemIds) { + state = state.copyWith( + itemQueries: { + ...state.itemQueries, + queryKey: CacheEntry(data: itemIds, cachedAt: DateTime.now()), + }, + ); + } + + /// Instantly clears all cached data, resetting the state to be empty. + /// + /// This is useful for events like user logout. + void clear() { + state = const CatalogCache(); + } +} + +/// A global provider that exposes the [CatalogCacheNotifier] to the app. +/// +/// Widgets can use this provider to read from the cache or to call methods +/// on the notifier to update the cache. +final catalogCacheProvider = + StateNotifierProvider( + (ref) => CatalogCacheNotifier(), +); diff --git a/packages/flutter_catalog_riverpod/lib/src/cached_catalog_item_repository.dart b/packages/flutter_catalog_riverpod/lib/src/cached_catalog_item_repository.dart new file mode 100644 index 0000000..25f910c --- /dev/null +++ b/packages/flutter_catalog_riverpod/lib/src/cached_catalog_item_repository.dart @@ -0,0 +1,151 @@ +import "dart:convert"; + +import "package:flutter_catalog_interface/flutter_catalog_interface.dart"; +import "package:flutter_catalog_riverpod/src/cache_provider.dart"; +import "package:riverpod/riverpod.dart"; + +/// A repository that adds a caching layer on top of a source +/// [CatalogRepository]. It is completely decoupled from any specific +/// implementation (e.g., REST, GraphQL). +class CachedCatalogItemRepository + implements CatalogRepository { + /// Creates a [CachedCatalogItemRepository]. + /// + /// Requires a [sourceRepository] to fetch data from when the cache misses, + /// a Riverpod [ref] to interact with the cache provider, and a + /// [cacheDuration] to determine how long items stay valid in the cache. + const CachedCatalogItemRepository({ + required this.sourceRepository, + required this.ref, + required this.cacheDuration, + }); + + /// The underlying repository to fetch data from. + final CatalogRepository sourceRepository; + + /// A reference to the Riverpod provider system. + final Ref ref; + + /// The maximum duration for which a cached item is considered valid. + final Duration cacheDuration; + + /// Creates a stable, unique key from the query parameters. + String _generateQueryKey({ + String? userId, + Map? filters, + int? limit, + int? offset, + }) { + var keyParts = { + "userId": userId, + "filters": filters, + "limit": limit, + "offset": offset, + }; + var sortedJson = jsonEncode( + keyParts, + toEncodable: (o) => o is Map + ? Map.fromEntries( + o.entries.toList() + ..sort((a, b) => a.key.toString().compareTo(b.key.toString())), + ) + : o, + ); + return sortedJson; + } + + @override + Future fetchCatalogItemById(String itemId, String userId) async { + var cachedEntry = ref.read(catalogCacheProvider).items[itemId]; + if (cachedEntry != null && cachedEntry.isValid(cacheDuration)) { + return cachedEntry.data as T; + } + + var item = await sourceRepository.fetchCatalogItemById(itemId, userId); + if (item != null) { + ref.read(catalogCacheProvider.notifier).addItems([item]); + } + return item; + } + + @override + Future> fetchCatalogItems({ + required String userId, + LatLng? userLocation, + Map? filters, + int? limit, + int? offset, + }) async { + var queryKey = _generateQueryKey( + userId: userId, + filters: filters, + limit: limit, + offset: offset, + ); + var cachedQueryEntry = ref.read(catalogCacheProvider).itemQueries[queryKey]; + + if (cachedQueryEntry != null && cachedQueryEntry.isValid(cacheDuration)) { + var itemCache = ref.read(catalogCacheProvider).items; + var cachedItemIds = cachedQueryEntry.data; + var results = []; + var allItemsAreInCache = true; + + for (var itemId in cachedItemIds) { + var itemEntry = itemCache[itemId]; + if (itemEntry != null && itemEntry.isValid(cacheDuration)) { + results.add(itemEntry.data as T); + } else { + allItemsAreInCache = false; + break; + } + } + + if (allItemsAreInCache) { + return results; + } + } + + var items = await sourceRepository.fetchCatalogItems( + userId: userId, + userLocation: userLocation, + filters: filters, + limit: limit, + offset: offset, + ); + + if (items.isNotEmpty) { + ref.read(catalogCacheProvider.notifier).addItems(items); + ref + .read(catalogCacheProvider.notifier) + .addItemQuery(queryKey, items.map((i) => i.id).toList()); + } + + return items; + } + + @override + Future createCatalogItem(Map item) { + ref.read(catalogCacheProvider.notifier).clear(); + return sourceRepository.createCatalogItem(item); + } + + @override + Future updateCatalogItem(String itemId, Map item) { + ref.read(catalogCacheProvider.notifier).clear(); + return sourceRepository.updateCatalogItem(itemId, item); + } + + @override + Future deleteCatalogItem(String itemId) { + ref.read(catalogCacheProvider.notifier).clear(); + return sourceRepository.deleteCatalogItem(itemId); + } + + @override + Future toggleFavorite(String itemId, String userId) => + sourceRepository.toggleFavorite(itemId, userId); + + @override + Future uploadImage(XFile imageFile) => + sourceRepository.uploadImage(imageFile); +} diff --git a/packages/flutter_catalog_riverpod/lib/src/cached_catalog_user_repository.dart b/packages/flutter_catalog_riverpod/lib/src/cached_catalog_user_repository.dart new file mode 100644 index 0000000..db74c1e --- /dev/null +++ b/packages/flutter_catalog_riverpod/lib/src/cached_catalog_user_repository.dart @@ -0,0 +1,69 @@ +import "package:flutter_catalog_interface/flutter_catalog_interface.dart"; +import "package:flutter_catalog_riverpod/src/cache_provider.dart"; +import "package:riverpod/riverpod.dart"; + +/// A decorator that adds a caching layer on top of a source +/// [CatalogUserRepository]. It is completely decoupled from any specific +/// implementation (e.g., REST, GraphQL). +class CachedCatalogUserRepository + implements CatalogUserRepository { + /// Creates a [CachedCatalogUserRepository]. + /// + /// Requires a [sourceRepository] to fetch data from when the cache misses, + /// a Riverpod [ref] to interact with the cache provider, and a + /// [cacheDuration] to determine how long users stay valid in the cache. + const CachedCatalogUserRepository({ + required this.sourceRepository, + required this.ref, + required this.cacheDuration, + }); + + /// The underlying repository to fetch data from. + final CatalogUserRepository sourceRepository; + + /// A reference to the Riverpod provider system. + final Ref ref; + + /// The maximum duration for which a cached user is considered valid. + final Duration cacheDuration; + + @override + Future getUser(String userId) async { + var cachedEntry = ref.read(catalogCacheProvider).users[userId]; + if (cachedEntry != null && cachedEntry.isValid(cacheDuration)) { + return cachedEntry.data as T; + } + + var user = await sourceRepository.getUser(userId); + if (user != null) { + ref.read(catalogCacheProvider.notifier).addUsers([user]); + } + return user; + } + + @override + Future> getUsers(List userIds) async { + var cache = ref.read(catalogCacheProvider).users; + var validCachedUsers = []; + var idsToFetch = []; + + for (var id in userIds) { + var entry = cache[id]; + if (entry != null && entry.isValid(cacheDuration)) { + validCachedUsers.add(entry.data as T); + } else { + idsToFetch.add(id); + } + } + + if (idsToFetch.isNotEmpty) { + var newUsers = await sourceRepository.getUsers(idsToFetch); + if (newUsers.isNotEmpty) { + ref.read(catalogCacheProvider.notifier).addUsers(newUsers); + validCachedUsers.addAll(newUsers); + } + } + + return validCachedUsers; + } +} diff --git a/packages/flutter_catalog_riverpod/pubspec.yaml b/packages/flutter_catalog_riverpod/pubspec.yaml new file mode 100644 index 0000000..7fbc054 --- /dev/null +++ b/packages/flutter_catalog_riverpod/pubspec.yaml @@ -0,0 +1,19 @@ +name: flutter_catalog_riverpod +description: A Riverpod-based caching layer for the flutter_catalog user story. +version: 0.1.0 +homepage: https://github.com/Iconica-Development/flutter_feed +publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub + +environment: + sdk: ">=3.4.0 <4.0.0" + +dependencies: + riverpod: ^2.6.1 + flutter_catalog_interface: + hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub + version: ^0.1.0 + +dev_dependencies: + flutter_iconica_analysis: + hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub + version: ^7.0.0 diff --git a/packages/flutter_timeline/lib/l10n/app_localizations.dart b/packages/flutter_timeline/lib/l10n/app_localizations.dart index 050adde..7d865bf 100644 --- a/packages/flutter_timeline/lib/l10n/app_localizations.dart +++ b/packages/flutter_timeline/lib/l10n/app_localizations.dart @@ -59,15 +59,18 @@ import 'app_localizations_en.dart'; /// be consistent with the languages listed in the FlutterFeedLocalizations.supportedLocales /// property. abstract class FlutterFeedLocalizations { - FlutterFeedLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + FlutterFeedLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); final String localeName; static FlutterFeedLocalizations? of(BuildContext context) { - return Localizations.of(context, FlutterFeedLocalizations); + return Localizations.of( + context, FlutterFeedLocalizations); } - static const LocalizationsDelegate delegate = _FlutterFeedLocalizationsDelegate(); + static const LocalizationsDelegate delegate = + _FlutterFeedLocalizationsDelegate(); /// A list of this localizations delegate along with the default localizations /// delegates. @@ -79,7 +82,8 @@ abstract class FlutterFeedLocalizations { /// Additional delegates can be added by appending to this list in /// MaterialApp. This list does not have to be used at all if a custom list /// of delegates is preferred or required. - static const List> localizationsDelegates = >[ + static const List> localizationsDelegates = + >[ delegate, GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, @@ -87,9 +91,7 @@ abstract class FlutterFeedLocalizations { ]; /// A list of this localizations delegate's supported locales. - static const List supportedLocales = [ - Locale('en') - ]; + static const List supportedLocales = [Locale('en')]; /// Label shown when there are no posts to display in a timeline /// @@ -122,33 +124,34 @@ abstract class FlutterFeedLocalizations { String timelinePostDetailDate(DateTime date, DateTime time); } -class _FlutterFeedLocalizationsDelegate extends LocalizationsDelegate { +class _FlutterFeedLocalizationsDelegate + extends LocalizationsDelegate { const _FlutterFeedLocalizationsDelegate(); @override Future load(Locale locale) { - return SynchronousFuture(lookupFlutterFeedLocalizations(locale)); + return SynchronousFuture( + lookupFlutterFeedLocalizations(locale)); } @override - bool isSupported(Locale locale) => ['en'].contains(locale.languageCode); + bool isSupported(Locale locale) => + ['en'].contains(locale.languageCode); @override bool shouldReload(_FlutterFeedLocalizationsDelegate old) => false; } FlutterFeedLocalizations lookupFlutterFeedLocalizations(Locale locale) { - - // Lookup logic when only language code is specified. switch (locale.languageCode) { - case 'en': return FlutterFeedLocalizationsEn(); + case 'en': + return FlutterFeedLocalizationsEn(); } throw FlutterError( - 'FlutterFeedLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' - 'an issue with the localizations generation tool. Please file an issue ' - 'on GitHub with a reproducible sample app and the gen-l10n configuration ' - 'that was used.' - ); + 'FlutterFeedLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.'); }