diff --git a/android/app/build.gradle b/android/app/build.gradle index 98a29db6..b3fc4710 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -41,7 +41,7 @@ android { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.nextcloud_cookbook_flutter" minSdkVersion 18 - targetSdkVersion 28 + targetSdkVersion 29 versionCode flutterVersionCode.toInteger() versionName flutterVersionName testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/lib/src/blocs/recipe/recipe_bloc.dart b/lib/src/blocs/recipe/recipe_bloc.dart index cbae0388..328b5a86 100644 --- a/lib/src/blocs/recipe/recipe_bloc.dart +++ b/lib/src/blocs/recipe/recipe_bloc.dart @@ -12,17 +12,30 @@ class RecipeBloc extends Bloc { Stream mapEventToState(RecipeEvent event) async* { if (event is RecipeLoaded) { yield* _mapRecipeLoadedToState(event); + } else if (event is RecipeUpdated) { + yield* _mapRecipeUpdatedToState(event); } } - - Stream _mapRecipeLoadedToState(RecipeLoaded recipeLoaded) async* { + Stream _mapRecipeLoadedToState( + RecipeLoaded recipeLoaded) async* { 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 RecipeLoadFailure(); + yield RecipeUpdateFailure(_.toString()); } } -} \ No newline at end of file +} 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 6f11f028..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,15 +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 {} +class RecipeLoadSuccess extends RecipeSuccess { + RecipeLoadSuccess(Recipe recipe) : super(recipe); +} + +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 => [recipeId]; +} -class RecipeLoadInProgress extends RecipeState {} \ No newline at end of file +class RecipeUpdateInProgress extends RecipeState {} diff --git a/lib/src/models/recipe.dart b/lib/src/models/recipe.dart index 6a3ef037..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,16 +49,21 @@ 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") + Duration prepTime = data.containsKey("prepTime") && data["prepTime"] != "" ? IsoTimeFormat.toDuration(data["prepTime"]) : null; - Duration cookTime = data.containsKey("cookTime") + Duration cookTime = data.containsKey("cookTime") && data["cookTime"] != "" ? IsoTimeFormat.toDuration(data["cookTime"]) : null; - Duration totalTime = data.containsKey("totalTime") - ? IsoTimeFormat.toDuration(data["totalTime"]) - : null; + Duration totalTime = + 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, @@ -60,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/models/recipe_short.dart b/lib/src/models/recipe_short.dart index bc8d3753..01461fa1 100644 --- a/lib/src/models/recipe_short.dart +++ b/lib/src/models/recipe_short.dart @@ -12,7 +12,9 @@ class RecipeShort extends Equatable { String get imageUrl => _imageUrl; RecipeShort.fromJson(Map json) - : _recipeId = int.parse(json["recipe_id"]), + : _recipeId = json["recipe_id"] is int + ? json["recipe_id"] + : int.parse(json["recipe_id"]), _name = json["name"], _imageUrl = json["imageUrl"]; 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 17edc3c2..2e401a5c 100644 --- a/lib/src/screens/recipe_screen.dart +++ b/lib/src/screens/recipe_screen.dart @@ -1,4 +1,3 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -7,6 +6,7 @@ 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/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,26 +28,53 @@ 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 RecipeFailure) { + return Center( + child: Text(state.errorMsg), + ); } else { return Center( child: Text("FAILED"), ); } - }, - )); + }(), + ); + }), + ); } Widget _buildRecipeScreen(Recipe recipe) { @@ -93,106 +120,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 627605f9..1f29790c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,10 +11,10 @@ description: A new Flutter application. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.3.0+6 +version: 0.3.2+8 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"