diff --git a/lib/src/blocs/recipe/recipe_bloc.dart b/lib/src/blocs/recipe/recipe_bloc.dart index 8ab51b2b..328b5a86 100644 --- a/lib/src/blocs/recipe/recipe_bloc.dart +++ b/lib/src/blocs/recipe/recipe_bloc.dart @@ -12,6 +12,8 @@ class RecipeBloc extends Bloc { Stream mapEventToState(RecipeEvent event) async* { if (event is RecipeLoaded) { yield* _mapRecipeLoadedToState(event); + } else if (event is RecipeUpdated) { + yield* _mapRecipeUpdatedToState(event); } } @@ -20,9 +22,20 @@ class RecipeBloc extends Bloc { try { yield RecipeLoadInProgress(); final recipe = await dataRepository.fetchRecipe(recipeLoaded.recipeId); - yield RecipeLoadSuccess(recipe: recipe); + yield RecipeLoadSuccess(recipe); } catch (_) { yield RecipeLoadFailure(_.toString()); } } + + Stream _mapRecipeUpdatedToState( + RecipeUpdated recipeUpdated) async* { + try { + yield RecipeUpdateInProgress(); + int recipeId = await dataRepository.updateRecipe(recipeUpdated.recipe); + yield RecipeUpdateSuccess(recipeId); + } catch (_) { + yield RecipeUpdateFailure(_.toString()); + } + } } diff --git a/lib/src/blocs/recipe/recipe_event.dart b/lib/src/blocs/recipe/recipe_event.dart index fc7a87b8..e405fea8 100644 --- a/lib/src/blocs/recipe/recipe_event.dart +++ b/lib/src/blocs/recipe/recipe_event.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; +import 'package:nextcloud_cookbook_flutter/src/models/recipe.dart'; abstract class RecipeEvent extends Equatable { const RecipeEvent(); @@ -15,4 +16,13 @@ class RecipeLoaded extends RecipeEvent { @override List get props => [recipeId]; -} \ No newline at end of file +} + +class RecipeUpdated extends RecipeEvent { + final Recipe recipe; + + const RecipeUpdated(this.recipe); + + @override + List get props => [recipe]; +} diff --git a/lib/src/blocs/recipe/recipe_state.dart b/lib/src/blocs/recipe/recipe_state.dart index c948d5c8..9d5bbead 100644 --- a/lib/src/blocs/recipe/recipe_state.dart +++ b/lib/src/blocs/recipe/recipe_state.dart @@ -1,5 +1,4 @@ import 'package:equatable/equatable.dart'; -import 'package:flutter/cupertino.dart'; import 'package:nextcloud_cookbook_flutter/src/models/recipe.dart'; abstract class RecipeState extends Equatable { @@ -11,22 +10,45 @@ abstract class RecipeState extends Equatable { class RecipeInitial extends RecipeState {} -class RecipeLoadSuccess extends RecipeState { +class RecipeFailure extends RecipeState { + final String errorMsg; + + const RecipeFailure(this.errorMsg); + + @override + List get props => [errorMsg]; +} + +class RecipeSuccess extends RecipeState { final Recipe recipe; - RecipeLoadSuccess({@required this.recipe}); + const RecipeSuccess(this.recipe); @override List get props => [recipe]; } -class RecipeLoadFailure extends RecipeState { - final String errorMsg; +class RecipeLoadSuccess extends RecipeSuccess { + RecipeLoadSuccess(Recipe recipe) : super(recipe); +} - const RecipeLoadFailure(this.errorMsg); +class RecipeLoadFailure extends RecipeFailure { + RecipeLoadFailure(String errorMsg) : super(errorMsg); +} + +class RecipeLoadInProgress extends RecipeState {} + +class RecipeUpdateFailure extends RecipeFailure { + RecipeUpdateFailure(String errorMsg) : super(errorMsg); +} + +class RecipeUpdateSuccess extends RecipeState { + final int recipeId; + + const RecipeUpdateSuccess(this.recipeId); @override - List get props => [errorMsg]; + List get props => [recipeId]; } -class RecipeLoadInProgress extends RecipeState {} +class RecipeUpdateInProgress extends RecipeState {} diff --git a/lib/src/models/recipe.dart b/lib/src/models/recipe.dart index d2c5b3f3..3ddfff7e 100644 --- a/lib/src/models/recipe.dart +++ b/lib/src/models/recipe.dart @@ -11,10 +11,14 @@ class Recipe extends Equatable { final String description; final List recipeIngredient; final List recipeInstructions; + final List tool; final int recipeYield; final Duration prepTime; final Duration cookTime; final Duration totalTime; + final String keywords; + final String image; + final String url; const Recipe._( this.id, @@ -24,10 +28,14 @@ class Recipe extends Equatable { this.description, this.recipeIngredient, this.recipeInstructions, + this.tool, this.recipeYield, this.prepTime, this.cookTime, - this.totalTime); + this.totalTime, + this.keywords, + this.image, + this.url); factory Recipe(String jsonString) { Map data = json.decode(jsonString); @@ -41,6 +49,7 @@ class Recipe extends Equatable { data["recipeIngredient"].cast().toList(); List recipeInstructions = data["recipeInstructions"].cast().toList(); + List tool = data["tool"].cast().toList(); int recipeYield = data["recipeYield"]; Duration prepTime = data.containsKey("prepTime") && data["prepTime"] != "" ? IsoTimeFormat.toDuration(data["prepTime"]) @@ -52,6 +61,9 @@ class Recipe extends Equatable { data.containsKey("totalTime") && data["totalTime"] != "" ? IsoTimeFormat.toDuration(data["totalTime"]) : null; + String keywords = data["keywords"]; + String image = data["image"]; + String url = data["url"]; return Recipe._( id, @@ -61,12 +73,89 @@ class Recipe extends Equatable { description, recipeIngredient, recipeInstructions, + tool, recipeYield, prepTime, cookTime, - totalTime); + totalTime, + keywords, + image, + url); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'imageUrl': imageUrl, + 'recipeCategory': recipeCategory, + 'description': description, + 'recipeIngredient': recipeIngredient, + 'recipeInstructions': recipeInstructions, + 'tool': tool, + 'recipeYield': recipeYield, + 'prepTime': prepTime, + 'cookTime': cookTime, + 'totalTime': totalTime, + 'keywords': keywords, + 'image': image, + 'url': url + }; + + MutableRecipe toMutableRecipe() { + MutableRecipe mutableRecipe = MutableRecipe(); + + mutableRecipe.id = this.id; + mutableRecipe.name = this.name; + mutableRecipe.imageUrl = this.imageUrl; + mutableRecipe.recipeCategory = this.recipeCategory; + mutableRecipe.description = this.description; + mutableRecipe.recipeIngredient = this.recipeIngredient; + mutableRecipe.recipeInstructions = this.recipeInstructions; + mutableRecipe.recipeYield = this.recipeYield; + mutableRecipe.prepTime = this.prepTime; + mutableRecipe.cookTime = this.cookTime; + mutableRecipe.totalTime = this.totalTime; + + return mutableRecipe; } @override List get props => [id]; } + +class MutableRecipe { + int id; + String name; + String imageUrl; + String recipeCategory; + String description; + List recipeIngredient; + List recipeInstructions; + List tool; + int recipeYield; + Duration prepTime; + Duration cookTime; + Duration totalTime; + String keywords; + String image; + String url; + + Recipe toRecipe() { + return Recipe._( + id, + name, + imageUrl, + recipeCategory, + description, + recipeIngredient, + recipeInstructions, + tool, + recipeYield, + prepTime, + cookTime, + totalTime, + keywords, + image, + url); + } +} diff --git a/lib/src/screens/form/recipe_form.dart b/lib/src/screens/form/recipe_form.dart new file mode 100644 index 00000000..7ad007bc --- /dev/null +++ b/lib/src/screens/form/recipe_form.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:nextcloud_cookbook_flutter/src/blocs/recipe/recipe.dart'; +import 'package:nextcloud_cookbook_flutter/src/models/recipe.dart'; +import 'package:validators/validators.dart'; + +class RecipeForm extends StatefulWidget { + final Recipe recipe; + + const RecipeForm(this.recipe); + + @override + _RecipeFormState createState() => _RecipeFormState(); +} + +class _RecipeFormState extends State { + final _formKey = GlobalKey(); + Recipe recipe; + MutableRecipe _mutableRecipe; + + @override + void initState() { + recipe = widget.recipe; + _mutableRecipe = recipe.toMutableRecipe(); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Form( + key: _formKey, + child: Column( + children: [ + TextFormField( + initialValue: recipe.name, + decoration: InputDecoration(hintText: "Recipe Name"), + onSaved: (value) { + _mutableRecipe.name = value; + }, + ), + TextFormField( + initialValue: recipe.recipeYield.toString(), + keyboardType: TextInputType.number, + decoration: InputDecoration(hintText: "Recipe Yield"), + validator: (value) => + isNumeric(value) ? null : "Recipe Yield should be a number", + onSaved: (value) => _mutableRecipe.recipeYield = int.parse(value), + ), + RaisedButton( + onPressed: () { + if (_formKey.currentState.validate()) { + _formKey.currentState.save(); + + BlocProvider.of(context) + .add(RecipeUpdated(_mutableRecipe.toRecipe())); + } + }, + child: Text("Update"), + ) + ], + ), + ); + } +} diff --git a/lib/src/screens/login_page.dart b/lib/src/screens/login_page.dart index d7ee4583..82b15635 100644 --- a/lib/src/screens/login_page.dart +++ b/lib/src/screens/login_page.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; - import 'package:flutter_bloc/flutter_bloc.dart'; import '../blocs/authentication/authentication_bloc.dart'; @@ -13,7 +12,7 @@ class LoginPage extends StatelessWidget { appBar: AppBar( title: Text('Login'), ), - body: BlocProvider( + body: BlocProvider( create: (context) { return LoginBloc( authenticationBloc: BlocProvider.of(context), diff --git a/lib/src/screens/recipe_edit_screen.dart b/lib/src/screens/recipe_edit_screen.dart new file mode 100644 index 00000000..c0826e94 --- /dev/null +++ b/lib/src/screens/recipe_edit_screen.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:nextcloud_cookbook_flutter/src/blocs/recipe/recipe.dart'; +import 'package:nextcloud_cookbook_flutter/src/models/recipe.dart'; +import 'package:nextcloud_cookbook_flutter/src/screens/form/recipe_form.dart'; + +class RecipeEditScreen extends StatelessWidget { + final Recipe recipe; + + const RecipeEditScreen(this.recipe); + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () { + RecipeBloc recipeBloc = BlocProvider.of(context); + if (recipeBloc.state is RecipeUpdateFailure) { + recipeBloc.add(RecipeLoaded(recipeId: recipe.id)); + } + return Future(() => true); + }, + child: Scaffold( + appBar: AppBar( + title: BlocListener( + listener: (BuildContext context, RecipeState state) { + if (state is RecipeUpdateFailure) { + Scaffold.of(context).showSnackBar( + SnackBar( + content: Text("Update Failed: ${state.errorMsg}"), + backgroundColor: Colors.red, + ), + ); + } else if (state is RecipeUpdateSuccess) { + BlocProvider.of(context) + .add(RecipeLoaded(recipeId: state.recipeId)); + Navigator.pop(context); + } + }, + child: Text("Edit Recipe")), + ), + body: RecipeForm(recipe), + ), + ); + } +} diff --git a/lib/src/screens/recipe_screen.dart b/lib/src/screens/recipe_screen.dart index dda93704..6fa206e0 100644 --- a/lib/src/screens/recipe_screen.dart +++ b/lib/src/screens/recipe_screen.dart @@ -1,12 +1,13 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:nextcloud_cookbook_flutter/src/blocs/recipe/recipe.dart'; import 'package:nextcloud_cookbook_flutter/src/models/recipe.dart'; import 'package:nextcloud_cookbook_flutter/src/models/recipe_short.dart'; +import 'package:nextcloud_cookbook_flutter/src/screens/recipe_edit_screen.dart'; import 'package:nextcloud_cookbook_flutter/src/widget/authentication_cached_network_image.dart'; import 'package:nextcloud_cookbook_flutter/src/widget/duration_indicator.dart'; +import 'package:url_launcher/url_launcher.dart'; class RecipeScreen extends StatefulWidget { final RecipeShort recipeShort; @@ -28,20 +29,41 @@ class RecipeScreenState extends State { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text("Recipe"), - ), - body: BlocBuilder( - bloc: RecipeBloc()..add(RecipeLoaded(recipeId: recipeShort.recipeId)), + return BlocProvider( + create: (context) => + RecipeBloc()..add(RecipeLoaded(recipeId: recipeShort.recipeId)), + child: BlocBuilder( builder: (BuildContext context, RecipeState state) { + final recipeBloc = BlocProvider.of(context); + return Scaffold( + appBar: AppBar( + title: Text("Recipe"), + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + if (state is RecipeLoadSuccess) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return BlocProvider.value( + value: recipeBloc, + child: RecipeEditScreen(state.recipe)); + }, + ), + ); + } + }, + child: Icon(Icons.edit), + ), + body: () { if (state is RecipeLoadSuccess) { return _buildRecipeScreen(state.recipe); } else if (state is RecipeLoadInProgress) { return Center( child: CircularProgressIndicator(), ); - } else if (state is RecipeLoadFailure) { + } else if (state is RecipeFailure) { return Center( child: Text(state.errorMsg), ); @@ -50,8 +72,10 @@ class RecipeScreenState extends State { child: Text("FAILED"), ); } - }, - )); + }(), + ); + }), + ); } Widget _buildRecipeScreen(Recipe recipe) { @@ -97,106 +121,144 @@ class RecipeScreenState extends State { ), Padding( padding: const EdgeInsets.only(bottom: 10.0), - child: RichText( - text: TextSpan( - text: "Servings: ", - style: TextStyle( - color: Colors.black, fontWeight: FontWeight.bold), - children: [ - TextSpan( - text: recipe.recipeYield.toString(), + child: Row( + children: [ + RichText( + text: TextSpan( + text: "Servings: ", style: TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold), + children: [ + TextSpan( + text: recipe.recipeYield.toString(), + style: TextStyle( // color: Colors.black, - fontWeight: FontWeight.w400, - ), + fontWeight: FontWeight.w400, + ), + ) + ], + ), + ), + Spacer(), + if (recipe.url.isNotEmpty) + RaisedButton( + onPressed: () async { + if (await canLaunch(recipe.url)) { + await launch(recipe.url); + } + }, + child: Text("Source"), ) - ], - ), + ], ), ), Padding( padding: const EdgeInsets.only(bottom: 10.0), - child: Column( + child: Wrap( + alignment: WrapAlignment.center, + runSpacing: 10, + spacing: 10, children: [ - (recipe.prepTime != null) - ? DurationIndicator( - duration: recipe.prepTime, - name: "Preparation time") - : SizedBox(height: 0), - SizedBox(height: 10), - (recipe.cookTime != null) - ? DurationIndicator( - duration: recipe.cookTime, name: "Cooking time") - : SizedBox(height: 0), - SizedBox(height: 10), - (recipe.totalTime != null) - ? DurationIndicator( - duration: recipe.totalTime, name: "Total time") - : SizedBox(height: 0), + if (recipe.prepTime != null) + DurationIndicator( + duration: recipe.prepTime, + name: "Preparation time"), + if (recipe.cookTime != null) + DurationIndicator( + duration: recipe.cookTime, name: "Cooking time"), + if (recipe.totalTime != null) + DurationIndicator( + duration: recipe.totalTime, name: "Total time"), ], ), ), - Padding( - padding: const EdgeInsets.only(bottom: 10.0), - child: Text( - "Ingredients:", - style: TextStyle(fontWeight: FontWeight.bold), + if (recipe.tool.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 10.0), + child: ExpansionTile( + title: Text("Tools"), + children: [ + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(left: 15.0), + child: Text(recipe.tool.fold( + "", (p, e) => p + "- " + e.trim() + "\n")), + ), + ), + ], + ), + ), + if (recipe.recipeIngredient.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 10.0), + child: ExpansionTile( + title: Text("Ingredients"), + children: [ + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(left: 15.0), + child: Text(recipe.recipeIngredient.fold( + "", (p, e) => p + "- " + e.trim() + "\n")), + ), + ), + ], + ), ), - ), - Padding( - padding: const EdgeInsets.only(bottom: 10.0), - child: Text(recipe.recipeIngredient - .fold("", (p, e) => p + e + "\n")), - ), Padding( padding: const EdgeInsets.only(bottom: 10.0), - child: Text( - "Instructions:", - style: TextStyle(fontWeight: FontWeight.bold), + child: ExpansionTile( + title: Text("Instructions"), + initiallyExpanded: true, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: ListView.separated( + shrinkWrap: true, + physics: ClampingScrollPhysics(), + itemBuilder: (context, index) { + return GestureDetector( + onTap: () { + setState(() { + instructionsDone[index] = + !instructionsDone[index]; + }); + }, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 40, + height: 40, + margin: + EdgeInsets.only(right: 15, top: 10), + child: instructionsDone[index] + ? Icon(Icons.check) + : Center(child: Text("${index + 1}")), + decoration: ShapeDecoration( + shape: CircleBorder( + side: + BorderSide(color: Colors.grey)), + color: Colors.grey[300], + ), + ), + Expanded( + child: Text( + recipe.recipeInstructions[index]), + ), + ], + ), + ); + }, + separatorBuilder: (c, i) => SizedBox(height: 10), + itemCount: recipe.recipeInstructions.length, + ), + ), + ], ), ), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListView.separated( - shrinkWrap: true, - physics: ClampingScrollPhysics(), - itemBuilder: (context, index) { - return GestureDetector( - onTap: () { - setState(() { - instructionsDone[index] = - !instructionsDone[index]; - }); - }, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 40, - height: 40, - margin: EdgeInsets.only(right: 15, top: 10), - child: instructionsDone[index] - ? Icon(Icons.check) - : Center(child: Text("${index + 1}")), - decoration: ShapeDecoration( - shape: CircleBorder( - side: BorderSide(color: Colors.grey)), - color: Colors.grey[300], - ), - ), - Expanded( - child: Text(recipe.recipeInstructions[index]), - ), - ], - ), - ); - }, - separatorBuilder: (c, i) => SizedBox(height: 10), - itemCount: recipe.recipeInstructions.length, - ), - ], - ) ], ), ), diff --git a/lib/src/services/data_repository.dart b/lib/src/services/data_repository.dart index f1927206..86d38223 100644 --- a/lib/src/services/data_repository.dart +++ b/lib/src/services/data_repository.dart @@ -37,6 +37,10 @@ class DataRepository { return recipeProvider.fetchRecipe(id); } + Future updateRecipe(Recipe recipe) { + return recipeProvider.updateRecipe(recipe); + } + Future> fetchCategories() { return categoriesProvider.fetchCategories(); } diff --git a/lib/src/services/recipe_provider.dart b/lib/src/services/recipe_provider.dart index 6f9f4ad2..d33010e9 100644 --- a/lib/src/services/recipe_provider.dart +++ b/lib/src/services/recipe_provider.dart @@ -1,3 +1,4 @@ +import 'package:dio/dio.dart'; import 'package:http/http.dart'; import 'package:nextcloud_cookbook_flutter/src/models/app_authentication.dart'; import 'package:nextcloud_cookbook_flutter/src/models/recipe.dart'; @@ -27,4 +28,23 @@ class RecipeProvider { throw Exception("Failed to load RecipesShort!"); } } + + Future updateRecipe(Recipe recipe) async { + AppAuthentication appAuthentication = + UserRepository().currentAppAuthentication; + + try { + var response = await Dio().put( + "${appAuthentication.server}/index.php/apps/cookbook/api/recipes/${recipe.id}", + data: recipe.toJson(), + options: new Options( + contentType: "application/x-www-form-urlencoded", + headers: { + "authorization": appAuthentication.basicAuth, + })); + return response.data; + } catch (e) { + throw Exception(e); + } + } } diff --git a/pubspec.yaml b/pubspec.yaml index ac24a756..20619a0d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,7 +14,7 @@ description: A new Flutter application. version: 0.3.1+7 environment: - sdk: ">=2.1.0 <3.0.0" + sdk: ">=2.6.0 <3.0.0" dependencies: flutter: @@ -42,6 +42,9 @@ dependencies: # Search flappy_search_bar: ^1.7.2 + # Form data HTTP Cliennt + dio: 3.0.10 + cached_network_image: 2.1.0+1 flutter_spinkit: "^2.1.0"