From 78a715eea6b169b4939fe4c0a9f58bd76c170ff2 Mon Sep 17 00:00:00 2001 From: Nikolas Rimikis Date: Tue, 16 Jul 2024 19:15:49 +0200 Subject: [PATCH] feat(cookbook_app): add home view Signed-off-by: Nikolas Rimikis --- .cspell/dart_flutter.txt | 3 +- packages/neon_framework/example/pubspec.lock | 7 + .../example/pubspec_overrides.yaml | 4 +- .../cookbook_app/lib/l10n/arb/cookbook_en.arb | 39 +++- .../lib/l10n/cookbook_localizations.dart | 60 ++++-- .../lib/l10n/cookbook_localizations_en.dart | 32 +++ .../packages/cookbook_app/lib/l10n/l10n.dart | 15 ++ .../cookbook_app/lib/neon_cookbook.dart | 2 +- .../src/categories/bloc/categories_bloc.dart | 42 ++++ .../src/categories/bloc/categories_event.dart | 17 ++ .../src/categories/bloc/categories_state.dart | 51 +++++ .../lib/src/categories/categories.dart | 4 + .../utils/category_grid_delegate.dart | 44 ++++ .../lib/src/categories/utils/utils.dart | 1 + .../src/categories/view/categories_page.dart | 24 +++ .../src/categories/view/categories_view.dart | 72 +++++++ .../lib/src/categories/view/view.dart | 2 + .../src/categories/widgets/category_card.dart | 80 +++++++ .../lib/src/categories/widgets/widgets.dart | 1 + .../lib/src/home/cubit/home_cubit.dart | 13 ++ .../lib/src/home/cubit/home_state.dart | 24 +++ .../cookbook_app/lib/src/home/home.dart | 2 + .../lib/src/home/view/home_page.dart | 34 +++ .../lib/src/home/view/home_view.dart | 72 +++++++ .../cookbook_app/lib/src/home/view/view.dart | 2 + .../cookbook_app/lib/src/neon/neon.dart | 1 + .../cookbook_app/lib/src/neon/routes.dart | 3 +- .../recipe_list/bloc/recipe_list_bloc.dart | 46 ++++ .../recipe_list/bloc/recipe_list_event.dart | 17 ++ .../recipe_list/bloc/recipe_list_state.dart | 51 +++++ .../lib/src/recipe_list/recipe_list.dart | 3 + .../recipe_list/view/recipe_list_page.dart | 38 ++++ .../recipe_list/view/recipe_list_view.dart | 78 +++++++ .../lib/src/recipe_list/view/view.dart | 2 + .../src/recipe_list/widgets/date_chip.dart | 54 +++++ .../recipe_list/widgets/recipe_list_item.dart | 44 ++++ .../lib/src/recipe_list/widgets/widgets.dart | 2 + .../widgets/loading_refresh_indicator.dart | 202 ++++++++++++++++++ .../lib/src/widgets/recipe_image.dart | 57 +++++ .../cookbook_app/lib/src/widgets/widgets.dart | 2 + .../packages/cookbook_app/pubspec.yaml | 8 +- .../cookbook_app/pubspec_overrides.yaml | 4 +- 42 files changed, 1240 insertions(+), 19 deletions(-) create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/categories/bloc/categories_bloc.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/categories/bloc/categories_event.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/categories/bloc/categories_state.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/categories/categories.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/categories/utils/category_grid_delegate.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/categories/utils/utils.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/categories/view/categories_page.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/categories/view/categories_view.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/categories/view/view.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/categories/widgets/category_card.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/categories/widgets/widgets.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/home/cubit/home_cubit.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/home/cubit/home_state.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/home/home.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/home/view/home_page.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/home/view/home_view.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/home/view/view.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/bloc/recipe_list_bloc.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/bloc/recipe_list_event.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/bloc/recipe_list_state.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/recipe_list.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/view/recipe_list_page.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/view/recipe_list_view.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/view/view.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/widgets/date_chip.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/widgets/recipe_list_item.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/widgets/widgets.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/widgets/loading_refresh_indicator.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/widgets/recipe_image.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/widgets/widgets.dart diff --git a/.cspell/dart_flutter.txt b/.cspell/dart_flutter.txt index caaec313796..fdbb6b23c05 100644 --- a/.cspell/dart_flutter.txt +++ b/.cspell/dart_flutter.txt @@ -1,3 +1,4 @@ +arrowshape autofocus checkmark cupertino @@ -10,6 +11,7 @@ goldens lerp pubspec qrcode +Relayout steb sublist todos @@ -18,4 +20,3 @@ unawaited unfocus writeln xmark -arrowshape diff --git a/packages/neon_framework/example/pubspec.lock b/packages/neon_framework/example/pubspec.lock index cf4789a5692..1c2cc6784c2 100644 --- a/packages/neon_framework/example/pubspec.lock +++ b/packages/neon_framework/example/pubspec.lock @@ -220,6 +220,13 @@ packages: relative: true source: path version: "1.0.0" + cookbook_recipe_repository: + dependency: "direct overridden" + description: + path: "../packages/cookbook_recipe_repository" + relative: true + source: path + version: "1.0.0" cookie_store: dependency: "direct overridden" description: diff --git a/packages/neon_framework/example/pubspec_overrides.yaml b/packages/neon_framework/example/pubspec_overrides.yaml index 3e4aa070698..339d1b70104 100644 --- a/packages/neon_framework/example/pubspec_overrides.yaml +++ b/packages/neon_framework/example/pubspec_overrides.yaml @@ -1,9 +1,11 @@ -# melos_managed_dependency_overrides: account_repository,cookbook_app,cookie_store,dashboard_app,dynamite_runtime,files_app,interceptor_http_client,neon_framework,neon_http_client,neon_lints,news_app,nextcloud,notes_app,notifications_app,sort_box,talk_app +# melos_managed_dependency_overrides: account_repository,cookbook_app,cookbook_recipe_repository,cookie_store,dashboard_app,dynamite_runtime,files_app,interceptor_http_client,neon_framework,neon_http_client,neon_lints,news_app,nextcloud,notes_app,notifications_app,sort_box,talk_app dependency_overrides: account_repository: path: ../packages/account_repository cookbook_app: path: ../packages/cookbook_app + cookbook_recipe_repository: + path: ../packages/cookbook_recipe_repository cookie_store: path: ../../cookie_store dashboard_app: diff --git a/packages/neon_framework/packages/cookbook_app/lib/l10n/arb/cookbook_en.arb b/packages/neon_framework/packages/cookbook_app/lib/l10n/arb/cookbook_en.arb index 7dc6d05dc6d..96275440d15 100644 --- a/packages/neon_framework/packages/cookbook_app/lib/l10n/arb/cookbook_en.arb +++ b/packages/neon_framework/packages/cookbook_app/lib/l10n/arb/cookbook_en.arb @@ -1,3 +1,40 @@ { - "@@locale": "en" + "@@locale": "en", + "recipeCreateButton": "Create Recipe", + "@recipeCreateButton": { + "type": "text", + "description": "Button to open the create recipe screen" + }, + "recipeListTitle": "Category: {name}", + "@recipeListTitle": { + "type": "text", + "description": "Title of the category view.", + "placeholders": { + "name": { + "description": "The name of the category.", + "type": "String", + "example": "Vegan" + } + } + }, + "noRecipes": "No recipes available.", + "errorLoadFailed": "Failed to load Recipe!", + "@errorLoadFailed": { + "type": "text", + "description": "Error message when fetching the recipes failed." + }, + "categoryAll": "All Recipes", + "categoryUncategorized": "Uncategorized", + "categoryItems": "{count, plural, =0{no items} =1 {1 item} other {{count} items}}", + "@categoryItems": { + "type": "text", + "description": "Number of recipes in a category.", + "placeholders": { + "count": { + "description": "The number of recipes.", + "type": "int", + "example": "4" + } + } + } } diff --git a/packages/neon_framework/packages/cookbook_app/lib/l10n/cookbook_localizations.dart b/packages/neon_framework/packages/cookbook_app/lib/l10n/cookbook_localizations.dart index 558356e4518..993f65c7b25 100644 --- a/packages/neon_framework/packages/cookbook_app/lib/l10n/cookbook_localizations.dart +++ b/packages/neon_framework/packages/cookbook_app/lib/l10n/cookbook_localizations.dart @@ -89,10 +89,49 @@ abstract class CookbookLocalizations { ]; /// A list of this localizations delegate's supported locales. - static const List supportedLocales = [ - Locale('en') - ]; + static const List supportedLocales = [Locale('en')]; + + /// Button to open the create recipe screen + /// + /// In en, this message translates to: + /// **'Create Recipe'** + String get recipeCreateButton; + + /// Title of the category view. + /// + /// In en, this message translates to: + /// **'Category: {name}'** + String recipeListTitle(String name); + + /// No description provided for @noRecipes. + /// + /// In en, this message translates to: + /// **'No recipes available.'** + String get noRecipes; + + /// Error message when fetching the recipes failed. + /// + /// In en, this message translates to: + /// **'Failed to load Recipe!'** + String get errorLoadFailed; + + /// No description provided for @categoryAll. + /// + /// In en, this message translates to: + /// **'All Recipes'** + String get categoryAll; + + /// No description provided for @categoryUncategorized. + /// + /// In en, this message translates to: + /// **'Uncategorized'** + String get categoryUncategorized; + /// Number of recipes in a category. + /// + /// In en, this message translates to: + /// **'{count, plural, =0{no items} =1 {1 item} other {{count} items}}'** + String categoryItems(int count); } class _CookbookLocalizationsDelegate extends LocalizationsDelegate { @@ -111,17 +150,14 @@ class _CookbookLocalizationsDelegate extends LocalizationsDelegate 'Create Recipe'; + + @override + String recipeListTitle(String name) { + return 'Category: $name'; + } + + @override + String get noRecipes => 'No recipes available.'; + + @override + String get errorLoadFailed => 'Failed to load Recipe!'; + + @override + String get categoryAll => 'All Recipes'; + + @override + String get categoryUncategorized => 'Uncategorized'; + @override + String categoryItems(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count items', + one: '1 item', + zero: 'no items', + ); + return '$_temp0'; + } } diff --git a/packages/neon_framework/packages/cookbook_app/lib/l10n/l10n.dart b/packages/neon_framework/packages/cookbook_app/lib/l10n/l10n.dart index 3e200c9f59f..f118c44beb3 100644 --- a/packages/neon_framework/packages/cookbook_app/lib/l10n/l10n.dart +++ b/packages/neon_framework/packages/cookbook_app/lib/l10n/l10n.dart @@ -11,3 +11,18 @@ extension AppLocalizationsX on BuildContext { /// `CookbookLocalizations.of(this)`. CookbookLocalizations get l10n => CookbookLocalizations.of(this); } + +/// Extension for custom localizations constructed from other ones. +extension CookbookLocalizationsX on CookbookLocalizations { + /// Translates the special categories '_' (all recipes) and '*' (uncategorized). + /// + /// In en, this message translates to: + /// **'{name, select, _{[categoryAll]} *{[categoryUncategorized]} other{{name}}}'** + String categoryName(String name) { + return switch (name) { + '_' => categoryAll, + '*' => categoryUncategorized, + _ => name, + }; + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/neon_cookbook.dart b/packages/neon_framework/packages/cookbook_app/lib/neon_cookbook.dart index 9462962dbb7..be09f6a35a6 100644 --- a/packages/neon_framework/packages/cookbook_app/lib/neon_cookbook.dart +++ b/packages/neon_framework/packages/cookbook_app/lib/neon_cookbook.dart @@ -31,7 +31,7 @@ final class CookbookApp extends AppImplementation CookbookBloc buildBloc(Account account) => CookbookBloc(); @override - final Widget page = const Placeholder(); + final Widget page = const HomePage(); @override final RouteBase route = $cookbookAppRoute; diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/categories/bloc/categories_bloc.dart b/packages/neon_framework/packages/cookbook_app/lib/src/categories/bloc/categories_bloc.dart new file mode 100644 index 00000000000..5d6cdcb7ddc --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/categories/bloc/categories_bloc.dart @@ -0,0 +1,42 @@ +import 'package:bloc/bloc.dart'; +import 'package:built_collection/built_collection.dart'; +import 'package:cookbook_recipe_repository/recipe_repository.dart'; +import 'package:equatable/equatable.dart'; + +part 'categories_event.dart'; +part 'categories_state.dart'; + +/// The bloc controlling the categories overview. +final class CategoriesBloc extends Bloc<_CategoriesEvent, CategoriesState> { + /// Creates a new categories bloc. + CategoriesBloc({ + required RecipeRepository recipeRepository, + }) : _recipeRepository = recipeRepository, + super(CategoriesState()) { + on(_onRefreshCategories); + + add(const RefreshCategories()); + } + + final RecipeRepository _recipeRepository; + + Future _onRefreshCategories( + RefreshCategories event, + Emitter emit, + ) async { + try { + emit(state.copyWith(status: CategoriesStatus.loading)); + + final categories = await _recipeRepository.readCategories(); + + emit( + state.copyWith( + categories: categories, + status: CategoriesStatus.success, + ), + ); + } on ReadCategoriesFailure { + emit(state.copyWith(status: CategoriesStatus.failure)); + } + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/categories/bloc/categories_event.dart b/packages/neon_framework/packages/cookbook_app/lib/src/categories/bloc/categories_event.dart new file mode 100644 index 00000000000..5c10af8f359 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/categories/bloc/categories_event.dart @@ -0,0 +1,17 @@ +part of 'categories_bloc.dart'; + +/// Events for the [CategoriesBloc]. +sealed class _CategoriesEvent extends Equatable { + const _CategoriesEvent(); + + @override + List get props => []; +} + +/// {@template RefreshCategories} +/// Event that triggers a reload of the categories. +/// {@endtemplate} +final class RefreshCategories extends _CategoriesEvent { + /// {@macro RefreshCategories} + const RefreshCategories(); +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/categories/bloc/categories_state.dart b/packages/neon_framework/packages/cookbook_app/lib/src/categories/bloc/categories_state.dart new file mode 100644 index 00000000000..8fccfda3c7b --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/categories/bloc/categories_state.dart @@ -0,0 +1,51 @@ +part of 'categories_bloc.dart'; + +/// The status of the [CategoriesState]. +enum CategoriesStatus { + /// When no event has been handled. + initial, + + /// When the categories are loading. + loading, + + /// When the categories have been fetched successfully. + success, + + /// When a failure occurred while loading the categories. + failure, +} + +/// State of the [CategoriesBloc]. +final class CategoriesState extends Equatable { + /// Creates a new state for managing the categories. + CategoriesState({ + BuiltList? categories, + this.status = CategoriesStatus.initial, + }) : categories = categories ?? BuiltList(); + + /// The list of categories. + /// + /// Defaults to an empty list. + final BuiltList categories; + + /// The status of the state. + final CategoriesStatus status; + + /// Creates a copies with mutated fields. + CategoriesState copyWith({ + BuiltList? categories, + String? error, + CategoriesStatus? status, + }) { + return CategoriesState( + categories: categories ?? this.categories, + status: status ?? this.status, + ); + } + + @override + List get props => [ + categories, + status, + ]; +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/categories/categories.dart b/packages/neon_framework/packages/cookbook_app/lib/src/categories/categories.dart new file mode 100644 index 00000000000..849bec56e57 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/categories/categories.dart @@ -0,0 +1,4 @@ +export 'bloc/categories_bloc.dart'; +export 'utils/utils.dart'; +export 'view/view.dart'; +export 'widgets/widgets.dart'; diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/categories/utils/category_grid_delegate.dart b/packages/neon_framework/packages/cookbook_app/lib/src/categories/utils/category_grid_delegate.dart new file mode 100644 index 00000000000..934399a905a --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/categories/utils/category_grid_delegate.dart @@ -0,0 +1,44 @@ +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; + +/// Controls the layout of the category cards in a grid. +class CategoryGridDelegate extends SliverGridDelegate { + /// Creates a delegate for the category card layout. + const CategoryGridDelegate({ + this.extent = 0.0, + }); + + /// The height extend the card will take. + final double extent; + + static const double _maxCrossAxisExtent = 250; + static const double _mainAxisSpacing = 8; + static const double _crossAxisSpacing = 8; + + @override + SliverGridLayout getLayout(SliverConstraints constraints) { + var crossAxisCount = (constraints.crossAxisExtent / (_maxCrossAxisExtent + _crossAxisSpacing)).ceil(); + // Ensure a minimum count of 1, can be zero and result in an infinite extent + // below when the window size is 0. + crossAxisCount = math.max(1, crossAxisCount); + final double usableCrossAxisExtent = math.max( + 0, + constraints.crossAxisExtent - _crossAxisSpacing * (crossAxisCount - 1), + ); + final childCrossAxisExtent = usableCrossAxisExtent / crossAxisCount; + final childMainAxisExtent = childCrossAxisExtent + extent; + + return SliverGridRegularTileLayout( + crossAxisCount: crossAxisCount, + mainAxisStride: childMainAxisExtent + _mainAxisSpacing, + crossAxisStride: childCrossAxisExtent + _crossAxisSpacing, + childMainAxisExtent: childMainAxisExtent, + childCrossAxisExtent: childCrossAxisExtent, + reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection), + ); + } + + @override + bool shouldRelayout(CategoryGridDelegate oldDelegate) => oldDelegate.extent != extent; +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/categories/utils/utils.dart b/packages/neon_framework/packages/cookbook_app/lib/src/categories/utils/utils.dart new file mode 100644 index 00000000000..c1a77ff5140 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/categories/utils/utils.dart @@ -0,0 +1 @@ +export 'category_grid_delegate.dart'; diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/categories/view/categories_page.dart b/packages/neon_framework/packages/cookbook_app/lib/src/categories/view/categories_page.dart new file mode 100644 index 00000000000..c9a88946437 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/categories/view/categories_page.dart @@ -0,0 +1,24 @@ +import 'package:cookbook_app/src/categories/categories.dart'; +import 'package:cookbook_recipe_repository/recipe_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// The page for showing the categories. +class CategoriesPage extends StatelessWidget { + /// Creates a new page for showing the categories. + const CategoriesPage({super.key}); + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => CategoriesBloc( + recipeRepository: context.read(), + ), + ), + ], + child: const CategoriesView(), + ); + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/categories/view/categories_view.dart b/packages/neon_framework/packages/cookbook_app/lib/src/categories/view/categories_view.dart new file mode 100644 index 00000000000..f4223de7d57 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/categories/view/categories_view.dart @@ -0,0 +1,72 @@ +import 'package:cookbook_app/l10n/l10n.dart'; +import 'package:cookbook_app/src/categories/categories.dart'; +import 'package:cookbook_app/src/widgets/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// The material design view for the categories page. +class CategoriesView extends StatelessWidget { + /// Creates a new categories view. + const CategoriesView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: LoadingRefreshIndicator( + isLoading: context.select( + (bloc) => bloc.state.status == CategoriesStatus.loading, + ), + onRefresh: () { + context.read().add(const RefreshCategories()); + }, + child: BlocConsumer( + listener: (context, state) { + if (state.status == CategoriesStatus.failure) { + final theme = Theme.of(context); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.errorLoadFailed, + style: TextStyle( + color: theme.colorScheme.onErrorContainer, + ), + ), + backgroundColor: theme.colorScheme.errorContainer, + ), + ); + } + }, + builder: (context, state) { + if (state.status == CategoriesStatus.initial) { + return const SizedBox(); + } + + if (state.status != CategoriesStatus.loading && state.categories.isEmpty) { + return Center( + child: Text(context.l10n.noRecipes), + ); + } + + // TODO: this is ugly code + final extent = CategoryCard.hightExtend(context); + return GridView.builder( + key: const Key('CategoriesView-grid'), + padding: const EdgeInsets.all(16), + gridDelegate: CategoryGridDelegate(extent: extent), + itemCount: state.categories.length, + itemBuilder: (context, index) { + final category = state.categories[index]; + + return CategoryCard( + category: category, + key: Key('categoryCard-item-$index'), + ); + }, + ); + }, + ), + ), + ); + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/categories/view/view.dart b/packages/neon_framework/packages/cookbook_app/lib/src/categories/view/view.dart new file mode 100644 index 00000000000..7f281551ad1 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/categories/view/view.dart @@ -0,0 +1,2 @@ +export 'categories_page.dart'; +export 'categories_view.dart'; diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/categories/widgets/category_card.dart b/packages/neon_framework/packages/cookbook_app/lib/src/categories/widgets/category_card.dart new file mode 100644 index 00000000000..b627b7ccff4 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/categories/widgets/category_card.dart @@ -0,0 +1,80 @@ +import 'package:cookbook_app/l10n/l10n.dart'; +import 'package:cookbook_app/src/recipe_list/recipe_list.dart'; +import 'package:cookbook_app/src/widgets/widgets.dart'; +import 'package:cookbook_recipe_repository/recipe_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:neon_framework/widgets.dart'; + +/// The material design card for the categories screen. +class CategoryCard extends StatelessWidget { + /// Creates a new categories card. + const CategoryCard({ + required this.category, + super.key, + }); + + /// The category displayed on the card. + final Category category; + + // TODO: this is really ugly. + static const double _spacer = 8; + static const _labelPadding = EdgeInsets.symmetric(horizontal: 8); + static TextStyle _nameStyle(BuildContext context) => Theme.of(context).textTheme.labelSmall!; + static TextStyle _itemStyle(BuildContext context) => Theme.of(context).textTheme.labelSmall!; + + /// Calculates the hight this card will take if built with the same context. + static double hightExtend(BuildContext context) => + _spacer + _itemStyle(context).fontSize! + _itemStyle(context).fontSize! + 2 * _labelPadding.horizontal; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return LayoutBuilder( + builder: (context, constraints) { + final size = constraints.maxWidth; + + return GestureDetector( + child: Card( + color: theme.colorScheme.secondaryContainer, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + NeonImageWrapper( + borderRadius: BorderRadius.circular(12), + child: RecipeImage( + recipeID: category.mainRecipeId, + size: Size.square(size), + ), + ), + const SizedBox(height: _spacer), + Padding( + padding: _labelPadding, + child: Text( + context.l10n.categoryName(category.name), + maxLines: 1, + style: _nameStyle(context), + ), + ), + Padding( + padding: _labelPadding, + child: Text( + context.l10n.categoryItems(category.recipeCount), + style: _itemStyle(context), + ), + ), + ], + ), + ), + onTap: () async => Navigator.of(context).push( + RecipeListPage.route( + category: category, + recipeRepository: context.read(), + ), + ), + ); + }, + ); + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/categories/widgets/widgets.dart b/packages/neon_framework/packages/cookbook_app/lib/src/categories/widgets/widgets.dart new file mode 100644 index 00000000000..b03af441852 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/categories/widgets/widgets.dart @@ -0,0 +1 @@ +export 'category_card.dart'; diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/home/cubit/home_cubit.dart b/packages/neon_framework/packages/cookbook_app/lib/src/home/cubit/home_cubit.dart new file mode 100644 index 00000000000..79fe15e14ab --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/home/cubit/home_cubit.dart @@ -0,0 +1,13 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; + +part 'home_state.dart'; + +/// Cubit for managing the home navigation. +class HomeCubit extends Cubit { + /// Creates a new home navigation cubit. + HomeCubit() : super(const HomeState()); + + /// Sets the home tab to the given one. + void setTab(HomeTab tab) => emit(HomeState(tab: tab)); +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/home/cubit/home_state.dart b/packages/neon_framework/packages/cookbook_app/lib/src/home/cubit/home_state.dart new file mode 100644 index 00000000000..8cf39ebb387 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/home/cubit/home_state.dart @@ -0,0 +1,24 @@ +part of 'home_cubit.dart'; + +/// Available tabs for the cookbook home screen. +enum HomeTab { + /// The Recipe categories. + categories, + + /// The timer screen. + timers, +} + +/// The state of the [HomeCubit]. +final class HomeState extends Equatable { + /// Creates a new home state. + const HomeState({ + this.tab = HomeTab.categories, + }); + + /// The active tab in the bottom navigation. + final HomeTab tab; + + @override + List get props => [tab]; +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/home/home.dart b/packages/neon_framework/packages/cookbook_app/lib/src/home/home.dart new file mode 100644 index 00000000000..0e9281ae5bc --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/home/home.dart @@ -0,0 +1,2 @@ +export 'cubit/home_cubit.dart'; +export 'view/view.dart'; diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/home/view/home_page.dart b/packages/neon_framework/packages/cookbook_app/lib/src/home/view/home_page.dart new file mode 100644 index 00000000000..39bf9b33a03 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/home/view/home_page.dart @@ -0,0 +1,34 @@ +import 'package:cookbook_app/src/home/home.dart'; +import 'package:cookbook_recipe_repository/recipe_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:neon_framework/models.dart'; +import 'package:nextcloud/cookbook.dart'; + +/// The main page of the cookbook app. +class HomePage extends StatelessWidget { + /// Creates a new home page for the cookbook app. + const HomePage({super.key}); + + @override + Widget build(BuildContext context) { + return MultiRepositoryProvider( + providers: [ + RepositoryProvider( + create: (context) { + final account = context.read(); + final categoryProvider = account.client.cookbook.categories; + final recipeProvider = account.client.cookbook.recipes; + + return RecipeRepository( + categoriesProvider: categoryProvider, + recipesProvider: recipeProvider, + ); + }, + ), + BlocProvider(create: (_) => HomeCubit()), + ], + child: const HomeView(), + ); + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/home/view/home_view.dart b/packages/neon_framework/packages/cookbook_app/lib/src/home/view/home_view.dart new file mode 100644 index 00000000000..edbf7997592 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/home/view/home_view.dart @@ -0,0 +1,72 @@ +import 'package:cookbook_app/l10n/l10n.dart'; +import 'package:cookbook_app/src/home/home.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// The material design view for the home page. +class HomeView extends StatelessWidget { + /// Creates a new home view. + const HomeView({super.key}); + + @override + Widget build(BuildContext context) { + final selectedTab = context.select((cubit) => cubit.state.tab); + + return Scaffold( + body: IndexedStack( + index: selectedTab.index, + children: const [Placeholder(), Placeholder()], + ), + floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + floatingActionButton: FloatingActionButton( + shape: const CircleBorder(), + tooltip: context.l10n.recipeCreateButton, + key: const Key('homeView_createRecipe_floatingActionButton'), + onPressed: () { + throw UnimplementedError('navigate to RecipeEditScreen'); + }, + child: const Icon(Icons.add), + ), + bottomNavigationBar: BottomAppBar( + shape: const CircularNotchedRectangle(), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _HomeTabButton( + groupValue: selectedTab, + value: HomeTab.categories, + icon: const Icon(Icons.receipt_long_outlined), + ), + _HomeTabButton( + groupValue: selectedTab, + value: HomeTab.timers, + icon: const Icon(Icons.alarm_add_outlined), + ), + ], + ), + ), + ); + } +} + +class _HomeTabButton extends StatelessWidget { + const _HomeTabButton({ + required this.groupValue, + required this.value, + required this.icon, + }); + + final HomeTab groupValue; + final HomeTab value; + final Widget icon; + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: () => context.read().setTab(value), + iconSize: 32, + color: groupValue != value ? null : Theme.of(context).colorScheme.secondary, + icon: icon, + ); + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/home/view/view.dart b/packages/neon_framework/packages/cookbook_app/lib/src/home/view/view.dart new file mode 100644 index 00000000000..53cf2b9b3e0 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/home/view/view.dart @@ -0,0 +1,2 @@ +export 'home_page.dart'; +export 'home_view.dart'; diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/neon/neon.dart b/packages/neon_framework/packages/cookbook_app/lib/src/neon/neon.dart index 3d570824aab..092bc3b1ea0 100644 --- a/packages/neon_framework/packages/cookbook_app/lib/src/neon/neon.dart +++ b/packages/neon_framework/packages/cookbook_app/lib/src/neon/neon.dart @@ -1,3 +1,4 @@ +export 'package:cookbook_app/src/home/home.dart'; export 'bloc.dart'; export 'options.dart'; export 'routes.dart'; diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/neon/routes.dart b/packages/neon_framework/packages/cookbook_app/lib/src/neon/routes.dart index 0855b1fee0b..76af15788c6 100644 --- a/packages/neon_framework/packages/cookbook_app/lib/src/neon/routes.dart +++ b/packages/neon_framework/packages/cookbook_app/lib/src/neon/routes.dart @@ -1,3 +1,4 @@ +import 'package:cookbook_app/src/neon/neon.dart'; import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:neon_framework/utils.dart'; @@ -16,5 +17,5 @@ class CookbookAppRoute extends NeonBaseAppRoute { const CookbookAppRoute(); @override - Widget build(BuildContext context, GoRouterState state) => const Placeholder(); + Widget build(BuildContext context, GoRouterState state) => const HomePage(); } diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/bloc/recipe_list_bloc.dart b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/bloc/recipe_list_bloc.dart new file mode 100644 index 00000000000..fcfdcc89bc6 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/bloc/recipe_list_bloc.dart @@ -0,0 +1,46 @@ +import 'package:bloc/bloc.dart'; +import 'package:built_collection/built_collection.dart'; +import 'package:cookbook_recipe_repository/recipe_repository.dart'; +import 'package:equatable/equatable.dart'; + +part 'recipe_list_event.dart'; +part 'recipe_list_state.dart'; + +/// The bloc controlling the recipes in a single category. +final class RecipeListBloc extends Bloc<_RecipeListEvent, RecipeListState> { + /// Creates a new recipe bloc. + RecipeListBloc({ + required RecipeRepository recipeRepository, + required this.category, + }) : _recipeRepository = recipeRepository, + super(RecipeListState()) { + on(_onRefreshRecipeList); + + add(const RefreshRecipeList()); + } + + final RecipeRepository _recipeRepository; + + /// The category this bloc manages. + final Category category; + + Future _onRefreshRecipeList( + RefreshRecipeList event, + Emitter emit, + ) async { + try { + emit(state.copyWith(status: RecipeListStatus.loading)); + + final recipes = await _recipeRepository.readCategory(name: category.name); + + emit( + state.copyWith( + recipes: recipes, + status: RecipeListStatus.success, + ), + ); + } on ReadCategoryFailure { + emit(state.copyWith(status: RecipeListStatus.failure)); + } + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/bloc/recipe_list_event.dart b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/bloc/recipe_list_event.dart new file mode 100644 index 00000000000..667718eb986 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/bloc/recipe_list_event.dart @@ -0,0 +1,17 @@ +part of 'recipe_list_bloc.dart'; + +/// Events for the [RecipeListBloc]. +sealed class _RecipeListEvent extends Equatable { + const _RecipeListEvent(); + + @override + List get props => []; +} + +/// {@template RefreshRecipeList} +/// Event that triggers a reload of the recipe list. +/// {@endtemplate} +final class RefreshRecipeList extends _RecipeListEvent { + /// {@macro RefreshRecipeList} + const RefreshRecipeList(); +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/bloc/recipe_list_state.dart b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/bloc/recipe_list_state.dart new file mode 100644 index 00000000000..2b7cf85077d --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/bloc/recipe_list_state.dart @@ -0,0 +1,51 @@ +part of 'recipe_list_bloc.dart'; + +/// The status of the [RecipeListState]. +enum RecipeListStatus { + /// When no event has been handled. + initial, + + /// When the categories are loading. + loading, + + /// When the categories have been fetched successfully. + success, + + /// When a failure occurred while loading the categories. + failure, +} + +/// State of the [RecipeListBloc]. +final class RecipeListState extends Equatable { + /// Creates a new state for managing the recipes in a category. + RecipeListState({ + BuiltList? recipes, + this.status = RecipeListStatus.initial, + }) : recipes = recipes ?? BuiltList(); + + /// The list of recipes. + /// + /// Defaults to an empty list. + final BuiltList recipes; + + /// The status of the state. + final RecipeListStatus status; + + /// Creates a copies with mutated fields. + RecipeListState copyWith({ + BuiltList? recipes, + String? error, + RecipeListStatus? status, + }) { + return RecipeListState( + recipes: recipes ?? this.recipes, + status: status ?? this.status, + ); + } + + @override + List get props => [ + recipes, + status, + ]; +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/recipe_list.dart b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/recipe_list.dart new file mode 100644 index 00000000000..0118734cdc0 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/recipe_list.dart @@ -0,0 +1,3 @@ +export 'bloc/recipe_list_bloc.dart'; +export 'view/view.dart'; +export 'widgets/widgets.dart'; diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/view/recipe_list_page.dart b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/view/recipe_list_page.dart new file mode 100644 index 00000000000..622bf915f3d --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/view/recipe_list_page.dart @@ -0,0 +1,38 @@ +import 'package:cookbook_app/src/recipe_list/recipe_list.dart'; +import 'package:cookbook_recipe_repository/recipe_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// The page for displaying the recipes in a category. +class RecipeListPage extends StatelessWidget { + /// Creates a new category page. + const RecipeListPage({super.key}); + + /// The route to navigate to this page. + static Route route({ + required Category category, + required RecipeRepository recipeRepository, + }) { + return MaterialPageRoute( + fullscreenDialog: true, + builder: (context) => MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => RecipeListBloc( + category: category, + // If the repository where to be inserted above the main app we could easily access it everywhere :( + recipeRepository: recipeRepository, //context.read(), + ), + ), + RepositoryProvider.value(value: recipeRepository), + ], + child: const RecipeListPage(), + ), + ); + } + + @override + Widget build(BuildContext context) { + return const RecipeListView(); + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/view/recipe_list_view.dart b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/view/recipe_list_view.dart new file mode 100644 index 00000000000..c4434128c0e --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/view/recipe_list_view.dart @@ -0,0 +1,78 @@ +import 'package:cookbook_app/l10n/l10n.dart'; +import 'package:cookbook_app/src/recipe_list/recipe_list.dart'; +import 'package:cookbook_app/src/widgets/widgets.dart'; +import 'package:cookbook_recipe_repository/recipe_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// The material design view for the recipe list page. +class RecipeListView extends StatelessWidget { + /// Creates a new recipe list view. + const RecipeListView({super.key}); + + @override + Widget build(BuildContext context) { + final category = context.select((bloc) => bloc.category); + + return Scaffold( + appBar: AppBar( + title: Text( + context.l10n.recipeListTitle( + context.l10n.categoryName(category.name), + ), + ), + ), + body: LoadingRefreshIndicator( + isLoading: context.select( + (bloc) => bloc.state.status == RecipeListStatus.loading, + ), + onRefresh: () { + context.read().add(const RefreshRecipeList()); + }, + child: BlocConsumer( + listener: (context, state) { + if (state.status == RecipeListStatus.failure) { + final theme = Theme.of(context); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.errorLoadFailed, + style: TextStyle( + color: theme.colorScheme.onErrorContainer, + ), + ), + backgroundColor: theme.colorScheme.errorContainer, + ), + ); + } + }, + builder: (context, state) { + if (state.status == RecipeListStatus.initial) { + return const SizedBox(); + } + + if (state.status != RecipeListStatus.loading && state.recipes.isEmpty) { + return Center( + child: Text(context.l10n.noRecipes), + ); + } + + return Padding( + padding: const EdgeInsets.all(8), + child: ListView.separated( + itemCount: state.recipes.length, + itemBuilder: (context, index) { + final recipe = state.recipes[index]; + + return RecipeListItem(recipe: recipe); + }, + separatorBuilder: (context, index) => const Divider(), + ), + ); + }, + ), + ), + ); + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/view/view.dart b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/view/view.dart new file mode 100644 index 00000000000..e76e300dce9 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/view/view.dart @@ -0,0 +1,2 @@ +export 'recipe_list_page.dart'; +export 'recipe_list_view.dart'; diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/widgets/date_chip.dart b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/widgets/date_chip.dart new file mode 100644 index 00000000000..0448824bc67 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/widgets/date_chip.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +/// A chip style UI element to display a date. +class DateChip extends StatelessWidget { + /// Creates a new date chip. + const DateChip({ + required this.date, + this.dateFormat = DateFormat.YEAR_NUM_MONTH_DAY, + this.icon, + super.key, + }); + + /// The date to display. + final DateTime date; + + /// The format to use for the date. + final String dateFormat; + + /// An optional leading icon to display in front of the date. + final IconData? icon; + + @override + Widget build(BuildContext context) { + final textStyle = Theme.of(context).textTheme.bodySmall!; + final colorScheme = Theme.of(context).colorScheme; + final content = DateFormat(dateFormat).format(date); + + return Card( + color: colorScheme.secondaryContainer, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: textStyle.fontSize, + color: colorScheme.onSecondaryContainer, + ), + const SizedBox(width: 4), + Text( + content, + style: textStyle.copyWith( + color: colorScheme.onSecondaryContainer, + ), + overflow: TextOverflow.fade, + ), + ], + ), + ), + ); + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/widgets/recipe_list_item.dart b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/widgets/recipe_list_item.dart new file mode 100644 index 00000000000..7f7d69e1e87 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/widgets/recipe_list_item.dart @@ -0,0 +1,44 @@ +import 'package:cookbook_app/src/recipe_list/recipe_list.dart'; +import 'package:cookbook_app/src/widgets/widgets.dart'; +import 'package:cookbook_recipe_repository/recipe_repository.dart'; +import 'package:flutter/material.dart'; + +/// The Item to display the recipe information in the recipe list. +class RecipeListItem extends StatelessWidget { + /// Creates a new recipe list item for the given [recipe]. + const RecipeListItem({ + required this.recipe, + super.key, + }); + + /// The recipe to display the information for. + final RecipeStub recipe; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: ClipRRect( + borderRadius: BorderRadius.circular(5), + child: RecipeImage( + recipeID: recipe.id, + size: const Size.square(80), + ), + ), + title: Text(recipe.name), + subtitle: Row( + children: [ + DateChip( + date: recipe.dateCreated, + icon: Icons.edit_calendar_outlined, + ), + if (recipe.dateModified != null && recipe.dateModified != recipe.dateCreated) + DateChip( + date: recipe.dateModified!, + icon: Icons.edit_outlined, + ), + ], + ), + onTap: () async => throw UnimplementedError('navigate to RecipePage'), + ); + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/widgets/widgets.dart b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/widgets/widgets.dart new file mode 100644 index 00000000000..0c2552eedbf --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/widgets/widgets.dart @@ -0,0 +1,2 @@ +export 'date_chip.dart'; +export 'recipe_list_item.dart'; diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/widgets/loading_refresh_indicator.dart b/packages/neon_framework/packages/cookbook_app/lib/src/widgets/loading_refresh_indicator.dart new file mode 100644 index 00000000000..bbf1df06254 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/widgets/loading_refresh_indicator.dart @@ -0,0 +1,202 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +/// The signature for a function that's called when the user has dragged a +/// [LoadingRefreshIndicator] far enough to demonstrate that they want the app +/// to refresh. +/// +/// Used by [LoadingRefreshIndicator.onRefresh]. +typedef LoadingRefreshCallback = void Function(); + +/// A loading indicator wrapper around [RefreshIndicator.adaptive]. +/// +/// This allows showing the refresh indicator from an external source. +class LoadingRefreshIndicator extends StatefulWidget { + /// Creates a new loading refresh indicator. + const LoadingRefreshIndicator({ + required this.onRefresh, + required this.child, + this.atTop = true, + this.isLoading = false, + this.displacement = 40.0, + this.edgeOffset = 0.0, + this.color, + this.backgroundColor, + this.notificationPredicate = defaultScrollNotificationPredicate, + this.semanticsLabel, + this.semanticsValue, + this.strokeWidth = RefreshProgressIndicator.defaultStrokeWidth, + this.triggerMode = RefreshIndicatorTriggerMode.onEdge, + super.key, + }); + + /// Whether the content is loading and the loading indicator should be shown. + final bool isLoading; + + /// Equivalent to the `atTop` argument in [RefreshIndicatorState.show]. + /// + /// It defaults to showing the indicator at the top. To show it at the + /// bottom, set `atTop` to false. + final bool atTop; + + /// The widget below this widget in the tree. + /// + /// The refresh indicator will be stacked on top of this child. The indicator + /// will appear when child's Scrollable descendant is over-scrolled. + /// + /// Typically a [ListView] or [CustomScrollView]. + final Widget child; + + /// The distance from the child's top or bottom [edgeOffset] where + /// the refresh indicator will settle. During the drag that exposes the refresh + /// indicator, its actual displacement may significantly exceed this value. + /// + /// In most cases, [displacement] distance starts counting from the parent's + /// edges. However, if [edgeOffset] is larger than zero then the [displacement] + /// value is calculated from that offset instead of the parent's edge. + final double displacement; + + /// The offset where [RefreshProgressIndicator] starts to appear on drag start. + /// + /// Depending whether the indicator is showing on the top or bottom, the value + /// of this variable controls how far from the parent's edge the progress + /// indicator starts to appear. This may come in handy when, for example, the + /// UI contains a top [Widget] which covers the parent's edge where the progress + /// indicator would otherwise appear. + /// + /// By default, the edge offset is set to 0. + /// + /// See also: + /// + /// * [displacement], can be used to change the distance from the edge that + /// the indicator settles. + final double edgeOffset; + + /// A function that's called when the user has dragged the refresh indicator + /// far enough to demonstrate that they want the app to refresh. The returned + /// [Future] must complete when the refresh operation is finished. + final LoadingRefreshCallback onRefresh; + + /// The progress indicator's foreground color. The current theme's + /// [ColorScheme.primary] by default. + final Color? color; + + /// The progress indicator's background color. The current theme's + /// [ThemeData.canvasColor] by default. + final Color? backgroundColor; + + /// A check that specifies whether a [ScrollNotification] should be + /// handled by this widget. + /// + /// By default, checks whether `notification.depth == 0`. Set it to something + /// else for more complicated layouts. + final ScrollNotificationPredicate notificationPredicate; + + /// {@macro flutter.progress_indicator.ProgressIndicator.semanticsLabel} + /// + /// This will be defaulted to [MaterialLocalizations.refreshIndicatorSemanticLabel] + /// if it is null. + final String? semanticsLabel; + + /// {@macro flutter.progress_indicator.ProgressIndicator.semanticsValue} + final String? semanticsValue; + + /// Defines [strokeWidth] for `RefreshIndicator`. + /// + /// By default, the value of [strokeWidth] is 2.0 pixels. + final double strokeWidth; + + /// Defines how this [RefreshIndicator] can be triggered when users overscroll. + /// + /// The [RefreshIndicator] can be pulled out in two cases, + /// 1, Keep dragging if the scrollable widget at the edge with zero scroll position + /// when the drag starts. + /// 2, Keep dragging after overscroll occurs if the scrollable widget has + /// a non-zero scroll position when the drag starts. + /// + /// If this is [RefreshIndicatorTriggerMode.anywhere], both of the cases above can be triggered. + /// + /// If this is [RefreshIndicatorTriggerMode.onEdge], only case 1 can be triggered. + /// + /// Defaults to [RefreshIndicatorTriggerMode.onEdge]. + final RefreshIndicatorTriggerMode triggerMode; + + @override + State createState() => _LoadingRefreshIndicatorState(); +} + +class _LoadingRefreshIndicatorState extends State { + final refreshIndicatorKey = GlobalKey(); + + Completer? completer; + late bool isLoading; + + @override + void initState() { + if (widget.isLoading) { + WidgetsBinding.instance.addPostFrameCallback((_) { + show(); + }); + } + + super.initState(); + } + + @override + void didUpdateWidget(covariant LoadingRefreshIndicator oldWidget) { + if (oldWidget.isLoading == widget.isLoading) { + return; + } + + if (widget.isLoading) { + show(); + } else { + hide(); + } + + super.didUpdateWidget(oldWidget); + } + + void show() { + isLoading = true; + unawaited( + refreshIndicatorKey.currentState!.show(atTop: widget.atTop), + ); + } + + void hide() { + completer?.complete(); + completer = null; + isLoading = false; + } + + Future onRefresh() async { + if (isLoading) { + isLoading = false; + } else { + widget.onRefresh(); + } + + completer = Completer(); + await completer!.future; + } + + @override + Widget build(BuildContext context) { + return RefreshIndicator.adaptive( + key: refreshIndicatorKey, + displacement: widget.displacement, + edgeOffset: widget.edgeOffset, + onRefresh: onRefresh, + color: widget.color, + backgroundColor: widget.backgroundColor, + notificationPredicate: widget.notificationPredicate, + semanticsLabel: widget.semanticsLabel, + semanticsValue: widget.semanticsValue, + strokeWidth: widget.strokeWidth, + triggerMode: widget.triggerMode, + child: widget.child, + ); + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/widgets/recipe_image.dart b/packages/neon_framework/packages/cookbook_app/lib/src/widgets/recipe_image.dart new file mode 100644 index 00000000000..2744cbaff80 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/widgets/recipe_image.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:neon_framework/models.dart'; +import 'package:neon_framework/widgets.dart'; +import 'package:nextcloud/cookbook.dart' as cookbook; +import 'package:vector_graphics/vector_graphics.dart'; + +/// Displays the image for a given recipe. +class RecipeImage extends StatelessWidget { + /// Creates a new recipe image. + const RecipeImage({ + required this.recipeID, + this.size, + super.key, + }); + + /// The id of the recipe to display the image for. + final String recipeID; + + /// The size of the recipe image to fetch. + /// + /// Uses [cookbook.GetImageSize] for the sizes and falls back to the full + /// resolution if hone is specified. + final Size? size; + + @override + Widget build(BuildContext context) { + final sizeParam = switch (size?.longestSide) { + != null && <= 16 => cookbook.GetImageSize.thumb16, + != null && <= 250 => cookbook.GetImageSize.thumb, + _ => cookbook.GetImageSize.full, + }; + + return NeonApiImage( + key: Key('recipe-image-$recipeID-$sizeParam'), + getRequest: (client) { + return client.cookbook.recipes.$getImage_Request(id: recipeID, size: sizeParam); + }, + etag: null, + expires: null, + account: context.read(), + errorBuilder: (context, error) { + return VectorGraphic( + key: Key('recipe-image-fallback-$sizeParam'), + width: size?.width, + height: size?.height, + loader: const AssetBytesLoader( + 'assets/app.svg.vec', + packageName: 'neon_cookbook', + ), + ); + }, + fit: BoxFit.fill, + size: size, + ); + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/widgets/widgets.dart b/packages/neon_framework/packages/cookbook_app/lib/src/widgets/widgets.dart new file mode 100644 index 00000000000..f8d0fd97e87 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/widgets/widgets.dart @@ -0,0 +1,2 @@ +export 'loading_refresh_indicator.dart'; +export 'recipe_image.dart'; diff --git a/packages/neon_framework/packages/cookbook_app/pubspec.yaml b/packages/neon_framework/packages/cookbook_app/pubspec.yaml index 0ee6acd7944..e93a73c0231 100644 --- a/packages/neon_framework/packages/cookbook_app/pubspec.yaml +++ b/packages/neon_framework/packages/cookbook_app/pubspec.yaml @@ -7,7 +7,12 @@ environment: flutter: ^3.22.0 dependencies: + bloc: ^8.0.0 built_collection: ^5.0.0 + cookbook_recipe_repository: + git: + url: https://github.com/nextcloud/neon + path: packages/neon_framework/packages/cookbook_recipe_repository equatable: ^2.0.0 flutter: sdk: flutter @@ -22,7 +27,8 @@ dependencies: git: url: https://github.com/nextcloud/neon path: packages/neon_framework - nextcloud: ^6.1.0 + nextcloud: ^7.0.0 + vector_graphics: ^1.0.0 dev_dependencies: build_runner: ^2.4.11 diff --git a/packages/neon_framework/packages/cookbook_app/pubspec_overrides.yaml b/packages/neon_framework/packages/cookbook_app/pubspec_overrides.yaml index 0abe46d2bdd..01898d83867 100644 --- a/packages/neon_framework/packages/cookbook_app/pubspec_overrides.yaml +++ b/packages/neon_framework/packages/cookbook_app/pubspec_overrides.yaml @@ -1,7 +1,9 @@ -# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: account_repository,cookbook_recipe_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box dependency_overrides: account_repository: path: ../account_repository + cookbook_recipe_repository: + path: ../cookbook_recipe_repository cookie_store: path: ../../../cookie_store dynamite_runtime: