Skip to content

Commit

Permalink
feat(neon_cookbook): add category view
Browse files Browse the repository at this point in the history
Signed-off-by: Nikolas Rimikis <[email protected]>
  • Loading branch information
Leptopoda committed Jul 16, 2024
1 parent 2950043 commit 8854e36
Show file tree
Hide file tree
Showing 14 changed files with 373 additions and 13 deletions.
12 changes: 12 additions & 0 deletions packages/neon/neon_cookbook/lib/l10n/arb/cookbook_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@
"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": {
Expand Down
25 changes: 13 additions & 12 deletions packages/neon/neon_cookbook/lib/l10n/cookbook_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,20 @@ abstract class CookbookLocalizations {
];

/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[
Locale('en')
];
static const List<Locale> supportedLocales = <Locale>[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:
Expand Down Expand Up @@ -144,17 +148,14 @@ class _CookbookLocalizationsDelegate extends LocalizationsDelegate<CookbookLocal
}

CookbookLocalizations lookupCookbookLocalizations(Locale locale) {


// Lookup logic when only language code is specified.
switch (locale.languageCode) {
case 'en': return CookbookLocalizationsEn();
case 'en':
return CookbookLocalizationsEn();
}

throw FlutterError(
'CookbookLocalizations.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.'
);
throw FlutterError('CookbookLocalizations.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.');
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ class CookbookLocalizationsEn extends CookbookLocalizations {
@override
String get recipeCreateButton => 'Create Recipe';

@override
String recipeListTitle(String name) {
return 'Category: $name';
}

@override
String get noRecipes => 'No recipes available.';

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:neon_cookbook/l10n/l10n.dart';
import 'package:neon_cookbook/src/recipe_list/recipe_list.dart';
import 'package:neon_cookbook/src/widgets/widgets.dart';
import 'package:neon_framework/widgets.dart';
import 'package:recipe_repository/recipe_repository.dart';
Expand Down Expand Up @@ -65,7 +67,12 @@ class CategoryCard extends StatelessWidget {
],
),
),
onTap: () => throw UnimplementedError('Navigate to RecipeListPage'),
onTap: () async => Navigator.of(context).push(
RecipeListPage.route(
category: category,
recipeRepository: context.read<RecipeRepository>(),
),
),
);
},
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import 'package:bloc/bloc.dart';
import 'package:built_collection/built_collection.dart';
import 'package:equatable/equatable.dart';
import 'package:recipe_repository/recipe_repository.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<RefreshRecipeList>(_onRefreshRecipeList);

add(const RefreshRecipeList());
}

final RecipeRepository _recipeRepository;

/// The category this bloc manages.
final Category category;

Future<void> _onRefreshRecipeList(
RefreshRecipeList event,
Emitter<RecipeListState> 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));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
part of 'recipe_list_bloc.dart';

/// Events for the [RecipeListBloc].
sealed class _RecipeListEvent extends Equatable {
const _RecipeListEvent();

@override
List<Object> get props => [];
}

/// {@template RefreshRecipeList}
/// Event that triggers a reload of the recipe list.
/// {@endtemplate}
final class RefreshRecipeList extends _RecipeListEvent {
/// {@macro RefreshRecipeList}
const RefreshRecipeList();
}
Original file line number Diff line number Diff line change
@@ -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<RecipeStub>? recipes,
this.status = RecipeListStatus.initial,
}) : recipes = recipes ?? BuiltList();

/// The list of recipes.
///
/// Defaults to an empty list.
final BuiltList<RecipeStub> recipes;

/// The status of the state.
final RecipeListStatus status;

/// Creates a copies with mutated fields.
RecipeListState copyWith({
BuiltList<RecipeStub>? recipes,
String? error,
RecipeListStatus? status,
}) {
return RecipeListState(
recipes: recipes ?? this.recipes,
status: status ?? this.status,
);
}

@override
List<Object> get props => [
recipes,
status,
];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export 'bloc/recipe_list_bloc.dart';
export 'view/view.dart';
export 'widgets/widgets.dart';
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:neon_cookbook/src/recipe_list/recipe_list.dart';
import 'package:recipe_repository/recipe_repository.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<void> 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<RecipeRepository>(),
),
),
RepositoryProvider.value(value: recipeRepository),
],
child: const RecipeListPage(),
),
);
}

@override
Widget build(BuildContext context) {
return const RecipeListView();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:neon_cookbook/l10n/l10n.dart';
import 'package:neon_cookbook/src/recipe_list/recipe_list.dart';
import 'package:neon_cookbook/src/widgets/widgets.dart';
import 'package:recipe_repository/recipe_repository.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<RecipeListBloc, Category>((bloc) => bloc.category);

return Scaffold(
appBar: AppBar(
title: Text(
context.l10n.recipeListTitle(
context.l10n.categoryName(category.name),
),
),
),
body: LoadingRefreshIndicator(
isLoading: context.select<RecipeListBloc, bool>(
(bloc) => bloc.state.status == RecipeListStatus.loading,
),
onRefresh: () {
context.read<RecipeListBloc>().add(const RefreshRecipeList());
},
child: BlocConsumer<RecipeListBloc, RecipeListState>(
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(),
),
);
},
),
),
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export 'recipe_list_page.dart';
export 'recipe_list_view.dart';
Original file line number Diff line number Diff line change
@@ -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,
),
],
),
),
);
}
}
Loading

0 comments on commit 8854e36

Please sign in to comment.