diff --git a/one_trip/android/app/build.gradle b/one_trip/android/app/build.gradle index 5ba5286..9feeaf7 100644 --- a/one_trip/android/app/build.gradle +++ b/one_trip/android/app/build.gradle @@ -47,7 +47,8 @@ android { applicationId "com.example.one_trip" // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration. - minSdkVersion flutter.minSdkVersion + minSdkVersion 18 + compileSdkVersion 33 targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/one_trip/android/app/src/main/AndroidManifest.xml b/one_trip/android/app/src/main/AndroidManifest.xml index 471601a..b415b0d 100644 --- a/one_trip/android/app/src/main/AndroidManifest.xml +++ b/one_trip/android/app/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@ json) { - return Ingredient( - id: json["id"] as int, - name: json["name"] as String, - inStock: json["in_stock"] as bool, - contentType: json["content_type"] as int, - objectID: json["object_id"] as int); - } -} diff --git a/one_trip/lib/api/models/listingredient.dart b/one_trip/lib/api/models/listingredient.dart new file mode 100644 index 0000000..03560c1 --- /dev/null +++ b/one_trip/lib/api/models/listingredient.dart @@ -0,0 +1,74 @@ +import 'dart:convert'; + +import 'package:one_trip/api/auth.dart'; +import 'package:one_trip/api/consts.dart'; +import 'package:http/http.dart' as http; + +class RecipeIngredient { + int id; + String name; + int list; + bool inCart; + + RecipeIngredient({ + required this.id, + required this.name, + required this.list, + required this.inCart, + }); + + factory RecipeIngredient.fromJson(Map json) { + return RecipeIngredient( + id: json["id"] as int, + name: json["name"] as String, + list: json["list"] as int, + inCart: json["in_cart"] as bool, + ); + } + + static Future create(String name, int recipeID) async { + const String requestURL = "$baseURL/api/listingredients/"; + String token = TokenSingleton().getToken(); + http.Response response = await http.post( + Uri.parse(requestURL), + headers: {"Authorization": "Token $token"}, + body: { + "name": name, + "recipe": "$recipeID", + }, + ); + + if (response.statusCode == 201) { + return RecipeIngredient.fromJson(jsonDecode(response.body)); + } else { + return null; + } + } + + Future patch(String name) async { + String requestURL = "$baseURL/api/listingredients/$id/"; + String token = TokenSingleton().getToken(); + + http.Response response = await http.patch(Uri.parse(requestURL), + headers: {"Authorization": "Token $token"}, body: {"name": name}); + + if (response.statusCode == 200) { + return RecipeIngredient.fromJson(jsonDecode(response.body)); + } + + return null; + } + + Future delete() async { + String requestURL = "$baseURL/api/listingredients/$id/"; + String token = TokenSingleton().getToken(); + http.Response response = await http.delete(Uri.parse(requestURL), + headers: {"Authorization": "Token $token"}); + + if (response.statusCode == 204) { + return true; + } + + return false; + } +} diff --git a/one_trip/lib/api/models/recipe.dart b/one_trip/lib/api/models/recipe.dart index 9dc2256..0619aae 100644 --- a/one_trip/lib/api/models/recipe.dart +++ b/one_trip/lib/api/models/recipe.dart @@ -3,14 +3,14 @@ import 'dart:convert'; import 'package:one_trip/api/auth.dart'; import 'package:one_trip/api/consts.dart'; import 'package:one_trip/api/models/homegroup.dart'; -import 'package:one_trip/api/models/ingredient.dart'; +import 'package:one_trip/api/models/recipeingredient.dart'; import 'package:http/http.dart' as http; class Recipe { int id; int homegroup; String name; - List ingredients; + List ingredients; Recipe( {required this.id, @@ -19,9 +19,9 @@ class Recipe { required this.homegroup}); factory Recipe.fromJson(Map json) { - List ingredients = []; + List ingredients = []; for (dynamic ingredient in json["ingredients"]) { - ingredients.add(Ingredient.fromJson(ingredient)); + ingredients.add(RecipeIngredient.fromJson(ingredient)); } return Recipe( id: json["id"] as int, @@ -56,10 +56,13 @@ class Recipe { for (int recipeID in group.recipes) { Recipe? recipe = await Recipe.get(recipeID); if (recipe != null) { + // TODO: implement sorted insert recipes.add(recipe); } } + recipes.sort(((a, b) => a.name.compareTo(b.name))); + return recipes; } @@ -78,4 +81,19 @@ class Recipe { return null; } + + Future delete() async { + String requestURL = "$baseURL/api/recipes/$id/"; + String token = TokenSingleton().getToken(); + final http.Response response = await http.delete( + Uri.parse(requestURL), + headers: {"Authorization": "Token $token"}, + ); + + if (response.statusCode == 204) { + return true; + } + + return false; + } } diff --git a/one_trip/lib/api/models/recipeingredient.dart b/one_trip/lib/api/models/recipeingredient.dart new file mode 100644 index 0000000..8fe2258 --- /dev/null +++ b/one_trip/lib/api/models/recipeingredient.dart @@ -0,0 +1,71 @@ +import 'dart:convert'; + +import 'package:one_trip/api/auth.dart'; +import 'package:one_trip/api/consts.dart'; +import 'package:http/http.dart' as http; + +class RecipeIngredient { + int id; + String name; + int recipe; + + RecipeIngredient({ + required this.id, + required this.name, + required this.recipe, + }); + + factory RecipeIngredient.fromJson(Map json) { + return RecipeIngredient( + id: json["id"] as int, + name: json["name"] as String, + recipe: json["recipe"] as int, + ); + } + + static Future create(String name, int recipeID) async { + const String requestURL = "$baseURL/api/recipeingredients/"; + String token = TokenSingleton().getToken(); + http.Response response = await http.post( + Uri.parse(requestURL), + headers: {"Authorization": "Token $token"}, + body: { + "name": name, + "recipe": "$recipeID", + }, + ); + + if (response.statusCode == 201) { + return RecipeIngredient.fromJson(jsonDecode(response.body)); + } else { + return null; + } + } + + Future patch(String name) async { + String requestURL = "$baseURL/api/recipeingredients/$id/"; + String token = TokenSingleton().getToken(); + + http.Response response = await http.patch(Uri.parse(requestURL), + headers: {"Authorization": "Token $token"}, body: {"name": name}); + + if (response.statusCode == 200) { + return RecipeIngredient.fromJson(jsonDecode(response.body)); + } + + return null; + } + + Future delete() async { + String requestURL = "$baseURL/api/recipeingredients/$id/"; + String token = TokenSingleton().getToken(); + http.Response response = await http.delete(Uri.parse(requestURL), + headers: {"Authorization": "Token $token"}); + + if (response.statusCode == 204) { + return true; + } + + return false; + } +} diff --git a/one_trip/lib/pages/list_page/list_page.dart b/one_trip/lib/pages/list_page/list_page.dart new file mode 100644 index 0000000..dd8dc6c --- /dev/null +++ b/one_trip/lib/pages/list_page/list_page.dart @@ -0,0 +1,52 @@ +// import 'package:flutter/material.dart'; +// import 'package:one_trip/api/models/recipe.dart'; +// import 'package:one_trip/api/models/user.dart'; +// import 'package:one_trip/pages/recipes_page/widgets/recipe_card_widget.dart'; +// import 'package:one_trip/widgets/text_entry_dialog.dart'; + +// class RecipesPage extends StatefulWidget { +// const RecipesPage({super.key}); + +// @override +// State createState() => _RecipesPageState(); +// } + +// class _RecipesPageState extends State { +// late Future> _recipes; +// late User _userInfo; + +// Future> _fetchList() async { +// User? userInfo = await User.getMe(); +// if (userInfo == null || userInfo.homegroup == null) { +// return []; +// } +// _userInfo = userInfo; + +// List recipes = await Recipe.getList(_userInfo.homegroup!); +// return recipes; +// } + +// @override +// void initState() { +// super.initState(); +// _recipes = _fetchList(); +// } + +// @override +// Widget build(BuildContext context) { +// return FutureBuilder( +// future: _recipes, +// builder: (context, snapshot) { +// if (snapshot.hasError) { +// return Text(snapshot.error.toString()); +// } else if (snapshot.hasData && +// snapshot.connectionState == ConnectionState.done) { +// return RecipeList( +// recipes: snapshot.data!, homegroup: _userInfo.homegroup!); +// } else { +// return const Center(child: CircularProgressIndicator()); +// } +// }, +// ); +// } +// } \ No newline at end of file diff --git a/one_trip/lib/pages/profile_page/widgets/invite_homegroup_dialog.dart b/one_trip/lib/pages/profile_page/widgets/invite_homegroup_dialog.dart index bef2969..50be508 100644 --- a/one_trip/lib/pages/profile_page/widgets/invite_homegroup_dialog.dart +++ b/one_trip/lib/pages/profile_page/widgets/invite_homegroup_dialog.dart @@ -16,6 +16,12 @@ class _InviteHomegroupDialogState extends State { ListViewState _listState = ListViewState.inactive; List selectedIDs = []; + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Card( diff --git a/one_trip/lib/pages/profile_page/widgets/profile_card_widget.dart b/one_trip/lib/pages/profile_page/widgets/profile_card_widget.dart index 27f12c6..5458868 100644 --- a/one_trip/lib/pages/profile_page/widgets/profile_card_widget.dart +++ b/one_trip/lib/pages/profile_page/widgets/profile_card_widget.dart @@ -32,20 +32,17 @@ class ProfileCard extends StatelessWidget { mainAxisSize: MainAxisSize.max, children: [ CircleAvatar( - backgroundColor: Theme.of(context).colorScheme.primary, + backgroundColor: Colors.black, radius: 42, child: CircleAvatar( radius: 40, backgroundImage: userInfo.imageUrl != null ? NetworkImage(userInfo.imageUrl!) - : Image( + : const Image( image: Svg('assets/images/person.svg', - color: Theme.of(context) - .colorScheme - .onPrimaryContainer), + color: Colors.black), ).image, - backgroundColor: - Theme.of(context).colorScheme.primaryContainer, + backgroundColor: Colors.white, // https://github.com/flutter/flutter/issues/42901#issuecomment-708050484 child: Material( shape: const CircleBorder(), diff --git a/one_trip/lib/pages/profile_page/widgets/user_chip.dart b/one_trip/lib/pages/profile_page/widgets/user_chip.dart index 63e564b..d1f67bb 100644 --- a/one_trip/lib/pages/profile_page/widgets/user_chip.dart +++ b/one_trip/lib/pages/profile_page/widgets/user_chip.dart @@ -16,16 +16,14 @@ class SmallUserChip extends StatelessWidget { children: [ CircleAvatar( radius: baseRadius, - backgroundColor: Theme.of(context).colorScheme.primary, + backgroundColor: Colors.black, child: CircleAvatar( radius: baseRadius - 2, - backgroundColor: Theme.of(context).colorScheme.primaryContainer, + backgroundColor: Colors.white, backgroundImage: user.imageUrl != null ? NetworkImage(user.imageUrl!) - : Image( - image: Svg('assets/images/person.svg', - color: - Theme.of(context).colorScheme.onPrimaryContainer), + : const Image( + image: Svg('assets/images/person.svg', color: Colors.black), ).image, ), ), diff --git a/one_trip/lib/pages/recipes_page/recipes_page.dart b/one_trip/lib/pages/recipes_page/recipes_page.dart index a03a583..f617a9d 100644 --- a/one_trip/lib/pages/recipes_page/recipes_page.dart +++ b/one_trip/lib/pages/recipes_page/recipes_page.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:one_trip/api/models/homegroup.dart'; import 'package:one_trip/api/models/recipe.dart'; import 'package:one_trip/api/models/user.dart'; import 'package:one_trip/pages/recipes_page/widgets/recipe_card_widget.dart'; @@ -14,16 +13,17 @@ class RecipesPage extends StatefulWidget { class _RecipesPageState extends State { late Future> _recipes; - late User _userInfo; + User? _userInfo; Future> _fetchList() async { User? userInfo = await User.getMe(); + _userInfo = userInfo; + if (userInfo == null || userInfo.homegroup == null) { return []; } - _userInfo = userInfo; - List recipes = await Recipe.getList(_userInfo.homegroup!); + List recipes = await Recipe.getList(userInfo.homegroup!); return recipes; } @@ -42,8 +42,18 @@ class _RecipesPageState extends State { return Text(snapshot.error.toString()); } else if (snapshot.hasData && snapshot.connectionState == ConnectionState.done) { - return RecipeList( - recipes: snapshot.data!, homegroup: _userInfo.homegroup!); + if (_userInfo == null) { + return const Center( + child: Text("Could not load user, try logging in again..."), + ); + } else if (_userInfo!.homegroup == null) { + return const Center( + child: Text("You must be in a homegroup to use this feature"), + ); + } else { + return RecipeList( + recipes: snapshot.data!, homegroup: _userInfo!.homegroup!); + } } else { return const Center(child: CircularProgressIndicator()); } @@ -63,6 +73,8 @@ class RecipeList extends StatefulWidget { class _RecipeListState extends State { int? _expandedCard; + late List _recipes; + final ScrollController _scrollController = ScrollController(); void showError(String text) { ScaffoldMessenger.of(context).showSnackBar( @@ -78,16 +90,30 @@ class _RecipeListState extends State { ); } + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + _recipes = widget.recipes; + } + @override Widget build(BuildContext context) { return Stack( children: [ ListView.separated( - padding: const EdgeInsets.all(8), - itemCount: widget.recipes.length, + controller: _scrollController, + padding: const EdgeInsets.fromLTRB( + 8, 8, 8, kFloatingActionButtonMargin + 48), + itemCount: _recipes.length, separatorBuilder: (context, index) => const SizedBox(height: 12), itemBuilder: (context, index) => RecipeCard( - recipe: widget.recipes[index], + recipe: _recipes[index], isExpanded: _expandedCard == index, onTap: () { setState(() { @@ -98,6 +124,29 @@ class _RecipeListState extends State { } }); }, + onDismiss: () async { + if (_expandedCard != null && _expandedCard! > index) { + _expandedCard = _expandedCard! - 1; + } + + bool success = await _recipes[index].delete(); + + if (!success) { + showError("Permanent deletion of recipe failed."); + } + + setState(() { + _recipes.removeAt(index); + }); + }, + onChanged: () async { + Recipe? newRecipe = await Recipe.get(_recipes[index].id); + if (newRecipe != null) { + setState(() { + _recipes[index] = newRecipe; + }); + } + }, ), ), Align( @@ -119,6 +168,16 @@ class _RecipeListState extends State { } Recipe? created = await Recipe.create(name, widget.homegroup); + if (created != null) { + setState(() { + _recipes.insert(0, created); + _expandedCard = 0; + }); + + _scrollController.animateTo(0, + duration: const Duration(milliseconds: 200), + curve: Curves.linear); + } }, label: Row( children: const [Icon(Icons.note_add), Text("New Recipe")], diff --git a/one_trip/lib/pages/recipes_page/widgets/recipe_card_widget.dart b/one_trip/lib/pages/recipes_page/widgets/recipe_card_widget.dart index 8014735..ed0ec63 100644 --- a/one_trip/lib/pages/recipes_page/widgets/recipe_card_widget.dart +++ b/one_trip/lib/pages/recipes_page/widgets/recipe_card_widget.dart @@ -1,131 +1,282 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; -import 'package:one_trip/api/models/ingredient.dart'; +import 'package:one_trip/api/models/recipeingredient.dart'; import 'package:one_trip/api/models/recipe.dart'; import 'package:one_trip/theme.dart'; import 'package:one_trip/widgets/text_entry_dialog.dart'; -class RecipeCard extends StatelessWidget { +class RecipeCard extends StatefulWidget { final Recipe recipe; final bool isExpanded; final Function() onTap; + final Function() onDismiss; + final Function() onChanged; const RecipeCard({ super.key, required this.recipe, required this.isExpanded, required this.onTap, + required this.onDismiss, + required this.onChanged, }); + @override + State createState() => _RecipeCardState(); +} + +class _RecipeCardState extends State with TickerProviderStateMixin { + double dismissAmount = 0.0; + bool willDismiss = false; + + late final AnimationController _verticalController = AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + ); + late final Animation _verticalAnimation = CurvedAnimation( + parent: _verticalController, + curve: Curves.easeInOut, + ); + + late final AnimationController _rotationController = AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + upperBound: 0.5, + ); + late final Animation _rotationAnimation = CurvedAnimation( + parent: _rotationController, + curve: Curves.easeInOut, + ); + + @override + void dispose() { + _verticalController.dispose(); + _rotationController.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant RecipeCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (!oldWidget.isExpanded && widget.isExpanded) { + _verticalController.forward(); + _rotationController.forward(); + } + + if (oldWidget.isExpanded && !widget.isExpanded) { + _verticalController.reverse(); + _rotationController.reverse(); + } + } + @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Card( - elevation: 10, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: const Radius.circular(10), - bottom: isExpanded ? Radius.zero : const Radius.circular(10)), - ), - margin: EdgeInsets.zero, - child: GestureDetector( - onTap: onTap, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - recipe.name, - style: Theme.of(context).textTheme.headlineSmall, - ), - ), - AnimatedRotation( - duration: const Duration(milliseconds: 200), - turns: isExpanded ? 0.5 : 0, - child: const Icon(Icons.expand_more, size: 30), - ), + return Material( + color: Colors.transparent, + elevation: 10, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Dismissible( + direction: widget.isExpanded + ? DismissDirection.none + : DismissDirection.endToStart, + key: Key("${widget.recipe.id}"), + onDismissed: (direction) => widget.onDismiss(), + onUpdate: (details) { + setState(() { + dismissAmount = details.progress; + willDismiss = details.reached; + }); + }, + background: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Color.fromARGB(255, 255, 0, 0), + Color.fromARGB(255, 255, 170, 170), ], ), ), - AnimatedSize( - duration: const Duration(milliseconds: 200), - child: isExpanded - ? IngredientSection(ingredients: recipe.ingredients) - : const SizedBox(), + child: Align( + alignment: Alignment.centerRight, + child: SizedBox( + width: 45, + child: Icon( + Icons.delete, + size: min(27.5 * dismissAmount + 20, 35), + color: willDismiss ? Colors.red : Colors.white, + ), + ), ), - ], + ), + child: Container( + color: Theme.of(context).cardColor, + margin: EdgeInsets.zero, + child: GestureDetector( + onTap: widget.onTap, + child: Padding( + padding: const EdgeInsets.all(8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + widget.recipe.name, + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + RotationTransition( + turns: _rotationAnimation, + child: const Icon(Icons.expand_more, size: 30)) + ]), + ), + ), + ), ), - ), + SizeTransition( + sizeFactor: _verticalAnimation, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + IngredientSection( + ingredients: widget.recipe.ingredients, + onChanged: widget.onChanged, + ), + ElevatedButton( + style: bottomButtonStyle.copyWith( + shape: const MaterialStatePropertyAll( + RoundedRectangleBorder())), + onPressed: () async { + String? name = await textEntryDialog( + context, "Ingredient Name", "Ingredient"); + + if (name == null || name == "") { + return; + } + + RecipeIngredient? ingredient = + await RecipeIngredient.create(name, widget.recipe.id); + if (ingredient != null) { + widget.onChanged(); + } + }, + child: const Text("Add Ingredient"), + ), + ], + ), + ), + ], ), - AnimatedSize( - duration: const Duration(milliseconds: 200), - child: isExpanded - ? ElevatedButton( - style: bottomButtonStyle, - onPressed: () async { - String? name = await textEntryDialog( - context, "Ingredient Name", "Ingredient"); - }, - child: const Text("Add Ingredient"), - ) - : const SizedBox(), - ) - ], + ), ); } } -class IngredientSection extends StatelessWidget { - final List ingredients; - const IngredientSection({super.key, required this.ingredients}); +class IngredientSection extends StatefulWidget { + final List ingredients; + final Function() onChanged; + const IngredientSection( + {super.key, required this.ingredients, required this.onChanged}); + + @override + State createState() => _IngredientSectionState(); +} + +class _IngredientSectionState extends State { + double dismissAmount = 0.0; + bool willDismiss = false; @override Widget build(BuildContext context) { - final TextStyle ingredientStyle = Theme.of(context) - .textTheme - .bodyLarge! - .copyWith(color: Theme.of(context).colorScheme.onSurface); - return Container( color: Theme.of(context).colorScheme.surface, - child: ListView.separated( - padding: const EdgeInsets.all(8), - itemCount: (ingredients.length / 2).ceil(), + child: ListView.builder( + padding: widget.ingredients.isEmpty + ? EdgeInsets.zero + : const EdgeInsets.all(8), + itemCount: widget.ingredients.length, shrinkWrap: true, - separatorBuilder: (context, index) => Divider( - color: index % 2 == 0 - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.error, - ), itemBuilder: (context, index) { - if (ingredients.length % 2 == 0 || - index * 2 <= ingredients.length - 2) { - return Row( - children: [ - Expanded( - child: Text( - ingredients[index * 2].name, - style: ingredientStyle, + return Column( + children: [ + Dismissible( + key: Key("${widget.ingredients[index].id}"), + direction: DismissDirection.endToStart, + background: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Color.fromARGB(255, 255, 0, 0), + Color.fromARGB(255, 255, 170, 170), + ], + ), + ), + child: Align( + alignment: Alignment.centerRight, + child: SizedBox( + width: 40, + child: Icon( + Icons.delete, + size: min(27.5 * dismissAmount + 20, 35), + color: willDismiss ? Colors.red : Colors.white, + ), + ), ), ), - Expanded( - child: Text(ingredients[index * 2 + 1].name, - style: ingredientStyle), - ) - ], - ); - } else { - return Row( - children: [ - Text(ingredients[index * 2].name, style: ingredientStyle), - ], - ); - } + onUpdate: (details) { + setState(() { + dismissAmount = details.progress; + willDismiss = details.reached; + }); + }, + onDismissed: (direction) async { + bool success = await widget.ingredients[index].delete(); + if (success) { + widget.onChanged(); + } + }, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + widget.ingredients[index].name, + style: Theme.of(context).textTheme.titleMedium, + )), + IconButton( + onPressed: () async { + String? name = await textEntryDialog( + context, "Change Ingredient Name", "Ingredient", + defaultValue: widget.ingredients[index].name); + + if (name == null || name == "") { + return; + } + + RecipeIngredient? changed = + await widget.ingredients[index].patch(name); + if (changed != null) { + widget.onChanged(); + } + }, + icon: const Icon(Icons.edit)), + ], + ), + ), + Divider( + height: 1, + color: index % 2 == 0 + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.error) + ], + ); }, ), ); diff --git a/one_trip/lib/pages/themetest.dart b/one_trip/lib/pages/themetest.dart index 9f9ef8e..37d588e 100644 --- a/one_trip/lib/pages/themetest.dart +++ b/one_trip/lib/pages/themetest.dart @@ -119,6 +119,15 @@ class ColorPage extends StatelessWidget { color: Theme.of(context).colorScheme.onSurfaceVariant), )), ), + Container( + padding: const EdgeInsets.all(8), + color: Theme.of(context).canvasColor, + child: Center( + child: Text( + "Canvas", + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), + )), + ) ], ); } diff --git a/one_trip/lib/screens/login_screen.dart b/one_trip/lib/screens/login_screen.dart index 5a85196..7703f85 100644 --- a/one_trip/lib/screens/login_screen.dart +++ b/one_trip/lib/screens/login_screen.dart @@ -172,6 +172,13 @@ class _LoginFormState extends State { ); } + @override + void dispose() { + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + @override void initState() { super.initState(); @@ -294,6 +301,15 @@ class _SignupFormState extends State { } } + @override + void dispose() { + _usernameController.dispose(); + _passwordController.dispose(); + _firstnameController.dispose(); + _lastnameController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Form( diff --git a/one_trip/lib/widgets/pagination_listview.dart b/one_trip/lib/widgets/pagination_listview.dart index 064a72d..74f3d39 100644 --- a/one_trip/lib/widgets/pagination_listview.dart +++ b/one_trip/lib/widgets/pagination_listview.dart @@ -55,6 +55,12 @@ class _PaginationListViewState extends State { }); } + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + @override void initState() { super.initState(); diff --git a/one_trip/lib/widgets/text_entry_dialog.dart b/one_trip/lib/widgets/text_entry_dialog.dart index af15b2a..ee8a5a6 100644 --- a/one_trip/lib/widgets/text_entry_dialog.dart +++ b/one_trip/lib/widgets/text_entry_dialog.dart @@ -14,6 +14,12 @@ class TextEntryForm extends StatefulWidget { class _TextEntryFormState extends State { late TextEditingController _textController; + @override + void dispose() { + _textController.dispose(); + super.dispose(); + } + @override void initState() { super.initState(); @@ -36,7 +42,11 @@ class _TextEntryFormState extends State { ), const Divider(), TextFormField( + autofocus: true, controller: _textController, + onFieldSubmitted: (value) { + Navigator.pop(context, value); + }, decoration: InputDecoration(hintText: widget.label), ), Padding( diff --git a/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index a2ec33f..96d3fee 100644 --- a/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,68 +1,68 @@ { - "images" : [ - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_16.png", - "scale" : "1x" + "info": { + "version": 1, + "author": "xcode" }, - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "2x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "1x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_64.png", - "scale" : "2x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_128.png", - "scale" : "1x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "2x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "1x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "2x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "1x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_1024.png", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} + "images": [ + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_16.png", + "scale": "1x" + }, + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "2x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "1x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_64.png", + "scale": "2x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_128.png", + "scale": "1x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "2x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "1x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "2x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "1x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_1024.png", + "scale": "2x" + } + ] +} \ No newline at end of file diff --git a/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png index 82b6f9d..8bad7cf 100644 Binary files a/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and b/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png index 13b35eb..7035bfa 100644 Binary files a/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and b/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png index 0a3f5fa..3882e63 100644 Binary files a/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and b/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png index bdb5722..9fcb687 100644 Binary files a/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and b/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png index f083318..4516dbe 100644 Binary files a/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and b/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png index 326c0e7..7f5955e 100644 Binary files a/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and b/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png index 2f1632c..e185c54 100644 Binary files a/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and b/one_trip/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/one_trip/pubspec.yaml b/one_trip/pubspec.yaml index 5911840..5215636 100644 --- a/one_trip/pubspec.yaml +++ b/one_trip/pubspec.yaml @@ -108,5 +108,8 @@ flutter_icons: image_path: "assets/icons/desktop.png" icon_size: 256 macos: + generate: true + image_path: "assets/icons/desktop.png" + web: generate: true image_path: "assets/icons/desktop.png" \ No newline at end of file diff --git a/one_trip/web/favicon.png b/one_trip/web/favicon.png index 8aaa46a..3882e63 100644 Binary files a/one_trip/web/favicon.png and b/one_trip/web/favicon.png differ diff --git a/one_trip/web/icons/Icon-192.png b/one_trip/web/icons/Icon-192.png index b749bfe..e3b3f8e 100644 Binary files a/one_trip/web/icons/Icon-192.png and b/one_trip/web/icons/Icon-192.png differ diff --git a/one_trip/web/icons/Icon-512.png b/one_trip/web/icons/Icon-512.png index 88cfd48..7f5955e 100644 Binary files a/one_trip/web/icons/Icon-512.png and b/one_trip/web/icons/Icon-512.png differ diff --git a/one_trip/web/icons/Icon-maskable-192.png b/one_trip/web/icons/Icon-maskable-192.png index eb9b4d7..e3b3f8e 100644 Binary files a/one_trip/web/icons/Icon-maskable-192.png and b/one_trip/web/icons/Icon-maskable-192.png differ diff --git a/one_trip/web/icons/Icon-maskable-512.png b/one_trip/web/icons/Icon-maskable-512.png index d69c566..7f5955e 100644 Binary files a/one_trip/web/icons/Icon-maskable-512.png and b/one_trip/web/icons/Icon-maskable-512.png differ diff --git a/one_trip/web/index.html b/one_trip/web/index.html index 3ec0459..38f4789 100644 --- a/one_trip/web/index.html +++ b/one_trip/web/index.html @@ -14,7 +14,7 @@ This is a placeholder for base href that will be replaced by the value of the `--base-href` argument provided to `flutter build`. --> - + diff --git a/one_trip/web/manifest.json b/one_trip/web/manifest.json index 48bb5de..09ca78b 100644 --- a/one_trip/web/manifest.json +++ b/one_trip/web/manifest.json @@ -32,4 +32,4 @@ "purpose": "maskable" } ] -} +} \ No newline at end of file diff --git a/one_trip/windows/runner/resources/app_icon.ico b/one_trip/windows/runner/resources/app_icon.ico index c04e20c..aaa3e0f 100644 Binary files a/one_trip/windows/runner/resources/app_icon.ico and b/one_trip/windows/runner/resources/app_icon.ico differ diff --git a/one_trip_api/api/admin.py b/one_trip_api/api/admin.py index 5032124..05326de 100644 --- a/one_trip_api/api/admin.py +++ b/one_trip_api/api/admin.py @@ -6,8 +6,16 @@ import nested_admin from api.models import * # Register your models here. -class IngredientInline(nested_admin.NestedGenericTabularInline): - model = Ingredient +# class IngredientInline(nested_admin.NestedGenericTabularInline): +# model = Ingredient +# extra = 0 + +class RecipeIngredientInline(nested_admin.NestedTabularInline): + model = RecipeIngredient + extra = 0 + +class ListIngredientInline(nested_admin.NestedTabularInline): + model = ListIngredient extra = 0 class UserInline(nested_admin.NestedTabularInline): @@ -28,20 +36,20 @@ class HomegroupInviteInline(nested_admin.NestedTabularInline): class RecipeInline(nested_admin.NestedTabularInline): model = Recipe - inlines = (IngredientInline,) + inlines = (RecipeIngredientInline,) extra = 0 @admin.register(Recipe) class RecipeAdmin(nested_admin.NestedModelAdmin): list_display = ("name",) - inlines = (IngredientInline,) + inlines = (RecipeIngredientInline,) @admin.register(Homegroup) class HomegroupAdmin(nested_admin.NestedModelAdmin): list_display = ("id", "name") - inlines = (UserInline, HomegroupInviteInline, RecipeInline, IngredientInline) + inlines = (UserInline, HomegroupInviteInline, RecipeInline,) @admin.register(List) class ListAdmin(nested_admin.NestedModelAdmin): list_display = ("homegroup",) - inlines = (RecipeInline, IngredientInline) + inlines = (ListIngredientInline,) diff --git a/one_trip_api/api/migrations/0003_recipeingredient_listingredient.py b/one_trip_api/api/migrations/0003_recipeingredient_listingredient.py new file mode 100644 index 0000000..9ce8ca0 --- /dev/null +++ b/one_trip_api/api/migrations/0003_recipeingredient_listingredient.py @@ -0,0 +1,31 @@ +# Generated by Django 4.1.3 on 2022-11-27 21:53 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0002_initial'), + ] + + operations = [ + migrations.CreateModel( + name='RecipeIngredient', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ingredients', to='api.recipe')), + ], + ), + migrations.CreateModel( + name='ListIngredient', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('in_cart', models.BooleanField(default=False)), + ('list', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ingredients', to='api.list')), + ], + ), + ] diff --git a/one_trip_api/api/migrations/0004_remove_recipe_list_delete_ingredient.py b/one_trip_api/api/migrations/0004_remove_recipe_list_delete_ingredient.py new file mode 100644 index 0000000..a773ba8 --- /dev/null +++ b/one_trip_api/api/migrations/0004_remove_recipe_list_delete_ingredient.py @@ -0,0 +1,20 @@ +# Generated by Django 4.1.3 on 2022-11-27 22:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0003_recipeingredient_listingredient'), + ] + + operations = [ + migrations.RemoveField( + model_name='recipe', + name='list', + ), + migrations.DeleteModel( + name='Ingredient', + ), + ] diff --git a/one_trip_api/api/models.py b/one_trip_api/api/models.py index 9dccbae..53dfe55 100644 --- a/one_trip_api/api/models.py +++ b/one_trip_api/api/models.py @@ -3,15 +3,6 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation # Create your models here. -class Ingredient(models.Model): - limit = models.Q(app_label="api", model="recipe") | models.Q(app_label="api", model="list") - content_type = models.ForeignKey(ContentType, limit_choices_to=limit, on_delete=models.CASCADE) - object_id = models.PositiveBigIntegerField() - content_object = GenericForeignKey() - - - name = models.CharField(max_length=50) - in_stock = models.BooleanField(default=False) class HomegroupInvite(models.Model): homegroup = models.ForeignKey("api.Homegroup", on_delete=models.CASCADE, related_name="invites", blank=True) @@ -33,15 +24,21 @@ class Homegroup(models.Model): return f"{self.id}: {self.name}" class List(models.Model): - # Foreign Key Recipe -> List [as recipes] - extra_ingredients = GenericRelation(Ingredient, related_query_name="extra_ingredients") + # Foreign Key ListIngredient -> List [as ingredients] homegroup = models.OneToOneField(Homegroup, on_delete=models.CASCADE, primary_key=True) class Recipe(models.Model): + # Foreign Key RecipeIngredient -> List [as ingredients] name = models.CharField(max_length=50) homegroup = models.ForeignKey(Homegroup, related_name="recipes", on_delete=models.CASCADE) - list = models.ForeignKey(List, related_name="recipes", on_delete=models.SET_NULL, blank=True, null=True) - ingredients = GenericRelation(Ingredient, related_query_name="ingredients") +class RecipeIngredient(models.Model): + name = models.CharField(max_length=50) + recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, related_name="ingredients") + +class ListIngredient(models.Model): + name = models.CharField(max_length=50) + list = models.ForeignKey(List, on_delete=models.CASCADE, related_name="ingredients") + in_cart = models.BooleanField(default=False) \ No newline at end of file diff --git a/one_trip_api/api/serializers.py b/one_trip_api/api/serializers.py index e9bbd1d..63a2c7f 100644 --- a/one_trip_api/api/serializers.py +++ b/one_trip_api/api/serializers.py @@ -2,11 +2,15 @@ from rest_framework import serializers from api.models import * from users.serializers import UserSerializer -class IngredientSerializer(serializers.ModelSerializer): +class RecipeIngredientSerializer(serializers.ModelSerializer): class Meta: - model = Ingredient - fields = ["id", "name", "in_stock", "content_type", "object_id"] - read_only_fields = ["id"] + model = RecipeIngredient + fields = ["id", "name", "recipe"] + +class ListIngredientSerializer(serializers.ModelSerializer): + class Meta: + model = ListIngredient + fields = ["id", "name", "list", "in_cart"] class RecipeSerializer(serializers.ModelSerializer): ingredients = serializers.SerializerMethodField() @@ -18,7 +22,20 @@ class RecipeSerializer(serializers.ModelSerializer): def get_ingredients(self, instance): ingredients = instance.ingredients.all().order_by("name") - return IngredientSerializer(ingredients, many=True).data + return RecipeIngredientSerializer(ingredients, many=True).data + +class ListSerializer(serializers.ModelSerializer): + ingredients = serializers.SerializerMethodField() + + class Meta: + model = List + fields = ["homegroup", "ingredients"] + read_only_fields = ["homegroup"] + + def get_ingredients(self, instance): + ingredients = instance.ingredients.all().order_by("name") + return ListIngredientSerializer(ingredients, many=True).data + class HomegroupSerializer(serializers.ModelSerializer): users = serializers.PrimaryKeyRelatedField(many=True, read_only=True) diff --git a/one_trip_api/api/urls.py b/one_trip_api/api/urls.py index 185995f..4ed61d2 100644 --- a/one_trip_api/api/urls.py +++ b/one_trip_api/api/urls.py @@ -4,10 +4,13 @@ from api import views router = routers.DefaultRouter() router.register(r'recipes', views.RecipeView) -router.register(r'ingredients', views.IngredientView) +router.register(r'lists', views.ListView) +router.register(r'recipeingredients', views.RecipeIngredientView) +router.register(r'listingredients', views.ListIngredientView) router.register(r'homegroups', views.HomegroupView) router.register(r'groupinvites', views.HomegroupInviteView) urlpatterns = [ - path('', include(router.urls)) + path('', include(router.urls)), + # path('ingredienttypes/', views.IngredientContentTypesView.as_view()) ] \ No newline at end of file diff --git a/one_trip_api/api/views.py b/one_trip_api/api/views.py index b2be567..fd8f661 100644 --- a/one_trip_api/api/views.py +++ b/one_trip_api/api/views.py @@ -1,5 +1,5 @@ -from rest_framework import viewsets - +from rest_framework import viewsets, mixins, views, status, permissions +from rest_framework.response import Response from api.serializers import * from api.models import * @@ -7,14 +7,22 @@ class RecipeView(viewsets.ModelViewSet): serializer_class = RecipeSerializer queryset = Recipe.objects.all() -class IngredientView(viewsets.ModelViewSet): - serializer_class = IngredientSerializer - queryset = Ingredient.objects.all() - class HomegroupView(viewsets.ModelViewSet): serializer_class = HomegroupSerializer queryset = Homegroup.objects.all() class HomegroupInviteView(viewsets.ModelViewSet): serializer_class = InviteSerializer - queryset = HomegroupInvite.objects.all() + queryset = HomegroupInvite.objects.all() + +class RecipeIngredientView(viewsets.ModelViewSet): + serializer_class = RecipeIngredientSerializer + queryset = RecipeIngredient.objects.all() + +class ListIngredientView(viewsets.ModelViewSet): + serializer_class = ListIngredientSerializer + queryset = ListIngredient.objects.all() + +class ListView(mixins.RetrieveModelMixin, viewsets.GenericViewSet): + serializer_class = ListSerializer + queryset = List.objects.all() \ No newline at end of file diff --git a/one_trip_api/gunicorn.service b/one_trip_api/gunicorn.service new file mode 100644 index 0000000..6d45976 --- /dev/null +++ b/one_trip_api/gunicorn.service @@ -0,0 +1,18 @@ +[Unit] +Description=gunicorn daemon +Requires=gunicorn.socket +After=network.target + +[Service] +User=django +Group=www-data +Environment="DJANGO_RELEASE=True" +WorkingDirectory=/opt/django/OneTrip/one_trip_api +ExecStart=/opt/django/OneTrip/one_trip_api/venv/bin/gunicorn \ + --access-logfile - \ + --workers 3 \ + --bind unix:/run/gunicorn.sock \ + one_trip_api.wsgi:application + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/one_trip_api/gunicorn.socket b/one_trip_api/gunicorn.socket new file mode 100644 index 0000000..cc9bfee --- /dev/null +++ b/one_trip_api/gunicorn.socket @@ -0,0 +1,8 @@ +[Unit] +Description=gunicorn socket + +[Socket] +ListenStream=/run/gunicorn.sock + +[Install] +WantedBy=sockets.target \ No newline at end of file diff --git a/one_trip_api/one_trip_api/settings/base.py b/one_trip_api/one_trip_api/settings/base.py index ec221bd..fcd15d7 100644 --- a/one_trip_api/one_trip_api/settings/base.py +++ b/one_trip_api/one_trip_api/settings/base.py @@ -13,7 +13,7 @@ https://docs.djangoproject.com/en/4.1/ref/settings/ from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. -BASE_DIR = Path(__file__).resolve().parent.parent +BASE_DIR = Path(__file__).resolve().parent.parent.parent STATIC_URL = "static/" MEDIA_URL = "/media/" @@ -24,7 +24,8 @@ REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': [ 'rest_framework.authentication.TokenAuthentication', 'rest_framework.authentication.SessionAuthentication', - ] + ], + 'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'] } # Application definition diff --git a/planning/backend-structure.dia b/planning/backend-structure.dia index 386f17c..7d96231 100644 Binary files a/planning/backend-structure.dia and b/planning/backend-structure.dia differ