diff --git a/one_trip/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/one_trip/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..d4ba5cb Binary files /dev/null and b/one_trip/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/one_trip/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/one_trip/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..8bcdc72 Binary files /dev/null and b/one_trip/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/one_trip/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/one_trip/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..d5af25b Binary files /dev/null and b/one_trip/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/one_trip/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/one_trip/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..466ab2c Binary files /dev/null and b/one_trip/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/one_trip/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/one_trip/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..9436920 Binary files /dev/null and b/one_trip/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/one_trip/assets/icons/adaptive.png b/one_trip/assets/icons/adaptive.png new file mode 100644 index 0000000..87272e2 Binary files /dev/null and b/one_trip/assets/icons/adaptive.png differ diff --git a/one_trip/lib/api/consts.dart b/one_trip/lib/api/consts.dart index f6bee9a..eaec0e1 100644 --- a/one_trip/lib/api/consts.dart +++ b/one_trip/lib/api/consts.dart @@ -1,4 +1,5 @@ -const String baseURL = "https://groceries.alaevens.ca"; -// const String baseURL = "http://192.168.0.16:8000"; +// const String baseURL = "https://groceries.alaevens.ca"; +const String baseURL = "http://192.168.0.16:8000"; +const String baseWsURL = "ws://192.168.0.16:8000"; const int resultsPerPage = 4; diff --git a/one_trip/lib/api/models/list.dart b/one_trip/lib/api/models/list.dart new file mode 100644 index 0000000..5b60843 --- /dev/null +++ b/one_trip/lib/api/models/list.dart @@ -0,0 +1,115 @@ +import 'dart:convert'; + +import 'package:one_trip/api/auth.dart'; +import 'package:one_trip/api/consts.dart'; +import 'package:one_trip/api/models/listingredient.dart'; +import 'package:http/http.dart' as http; +import 'package:one_trip/api/models/recipe.dart'; +import 'package:one_trip/api/models/recipeingredient.dart'; + +class ShoppingList { + List ingredients; + int updates; + int homegroup; + + ShoppingList({ + required this.ingredients, + required this.updates, + required this.homegroup, + }); + + factory ShoppingList.fromJson(Map json) { + List ingredients = []; + for (dynamic ingredient in json["ingredients"]) { + ingredients.add(ListIngredient.fromJson(ingredient)); + } + + return ShoppingList( + ingredients: ingredients, + updates: json["updates"] as int, + homegroup: json["homegroup"] as int); + } + + static Future get(int id) async { + String requestURL = "$baseURL/api/lists/$id/"; + String token = TokenSingleton().getToken(); + http.Response response = await http.get( + Uri.parse(requestURL), + headers: {"Authorization": "Token $token"}, + ); + + if (response.statusCode == 200) { + return ShoppingList.fromJson(jsonDecode(response.body)); + } + + return null; + } + + Future patch({int? updates}) async { + Map body = {}; + if (updates != null) { + body["updates"] = "$updates"; + } + + String requestURL = "$baseURL/api/lists/$homegroup/"; + String token = TokenSingleton().getToken(); + http.Response response = await http.patch(Uri.parse(requestURL), + headers: {"Authorization": "Token $token"}, body: body); + + if (response.statusCode == 200) { + return ShoppingList.fromJson(jsonDecode(response.body)); + } + + return null; + } + + Future addRecipe(int recipeID) async { + Recipe? recipe = await Recipe.get(recipeID); + + if (recipe == null) { + return null; + } + + bool anySuccesses = false; + for (RecipeIngredient ingredient in recipe.ingredients) { + ListIngredient? newIngredient = + await ListIngredient.create(ingredient.name, homegroup); + + if (newIngredient != null) { + anySuccesses = true; + } + } + + if (anySuccesses) { + return patch(updates: updates + 1); + } + + return null; + } + + Future clear() async { + bool anySuccess = false; + for (ListIngredient ingredient in ingredients) { + bool success = await ingredient.delete(); + + if (success) { + anySuccess = true; + } + } + + if (anySuccess) { + return patch(updates: updates + 1); + } + + return null; + } + + @override + bool operator ==(Object other) => + other is ShoppingList && + other.homegroup == homegroup && + other.ingredients == ingredients; + + @override + int get hashCode => Object.hashAll(ingredients); +} diff --git a/one_trip/lib/api/models/listingredient.dart b/one_trip/lib/api/models/listingredient.dart index 03560c1..8ad23a3 100644 --- a/one_trip/lib/api/models/listingredient.dart +++ b/one_trip/lib/api/models/listingredient.dart @@ -4,21 +4,21 @@ import 'package:one_trip/api/auth.dart'; import 'package:one_trip/api/consts.dart'; import 'package:http/http.dart' as http; -class RecipeIngredient { +class ListIngredient { int id; String name; int list; bool inCart; - RecipeIngredient({ + ListIngredient({ required this.id, required this.name, required this.list, required this.inCart, }); - factory RecipeIngredient.fromJson(Map json) { - return RecipeIngredient( + factory ListIngredient.fromJson(Map json) { + return ListIngredient( id: json["id"] as int, name: json["name"] as String, list: json["list"] as int, @@ -26,7 +26,7 @@ class RecipeIngredient { ); } - static Future create(String name, int recipeID) async { + static Future create(String name, int list) async { const String requestURL = "$baseURL/api/listingredients/"; String token = TokenSingleton().getToken(); http.Response response = await http.post( @@ -34,26 +34,35 @@ class RecipeIngredient { headers: {"Authorization": "Token $token"}, body: { "name": name, - "recipe": "$recipeID", + "list": "$list", }, ); if (response.statusCode == 201) { - return RecipeIngredient.fromJson(jsonDecode(response.body)); + return ListIngredient.fromJson(jsonDecode(response.body)); } else { return null; } } - Future patch(String name) async { + Future patch({String? name, bool? inCart}) async { String requestURL = "$baseURL/api/listingredients/$id/"; String token = TokenSingleton().getToken(); + Map body = {}; + if (name != null) { + body["name"] = name; + } + + if (inCart != null) { + body["in_cart"] = "$inCart"; + } + http.Response response = await http.patch(Uri.parse(requestURL), - headers: {"Authorization": "Token $token"}, body: {"name": name}); + headers: {"Authorization": "Token $token"}, body: body); if (response.statusCode == 200) { - return RecipeIngredient.fromJson(jsonDecode(response.body)); + return ListIngredient.fromJson(jsonDecode(response.body)); } return null; @@ -71,4 +80,14 @@ class RecipeIngredient { return false; } + + @override + bool operator ==(Object other) => + other is ListIngredient && + other.id == id && + other.name == name && + other.inCart == inCart; + + @override + int get hashCode => Object.hash(id, name, inCart); } diff --git a/one_trip/lib/api/models/recipe.dart b/one_trip/lib/api/models/recipe.dart index 0619aae..288b159 100644 --- a/one_trip/lib/api/models/recipe.dart +++ b/one_trip/lib/api/models/recipe.dart @@ -2,9 +2,9 @@ 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/recipeingredient.dart'; import 'package:http/http.dart' as http; +import 'package:one_trip/api/searchresult.dart'; class Recipe { int id; @@ -46,26 +46,51 @@ class Recipe { return null; } - static Future> getList(int groupID) async { - Homegroup? group = await Homegroup.get(groupID); - if (group == null) { - return []; - } + static Future> getList() async { + const String requestURL = "$baseURL/api/recipes/"; + + String token = TokenSingleton().getToken(); + http.Response response = await http.get( + Uri.parse(requestURL), + headers: {"Authorization": "Token $token"}, + ); List recipes = []; - for (int recipeID in group.recipes) { - Recipe? recipe = await Recipe.get(recipeID); - if (recipe != null) { - // TODO: implement sorted insert - recipes.add(recipe); + if (response.statusCode == 200) { + var body = jsonDecode(response.body); + for (var recipe in body) { + recipes.add(Recipe.fromJson(recipe)); } } - recipes.sort(((a, b) => a.name.compareTo(b.name))); - return recipes; } + static Future> search(String query, int page) async { + String requestURL = "$baseURL/api/searchrecipes/?page=$page&search=$query"; + requestURL = requestURL.replaceAll(RegExp(r"\s+"), "+"); + + String token = TokenSingleton().getToken(); + final http.Response response = await http.get( + Uri.parse(requestURL), + headers: {"Authorization": "Token $token"}, + ); + + if (response.statusCode == 200) { + Map json = jsonDecode(response.body); + List recipes = []; + for (var recipeObject in json["results"]) { + Recipe r = Recipe.fromJson(recipeObject); + recipes.add(r); + } + + return SearchResult( + results: recipes, next: json["next"] as String?); + } + + return SearchResult(results: [], next: null); + } + static Future create(String name, int group) async { String requestURL = "$baseURL/api/recipes/"; String token = TokenSingleton().getToken(); diff --git a/one_trip/lib/api/models/simpleuser.dart b/one_trip/lib/api/models/simpleuser.dart index 68ed7b2..8db949a 100644 --- a/one_trip/lib/api/models/simpleuser.dart +++ b/one_trip/lib/api/models/simpleuser.dart @@ -3,13 +3,14 @@ import 'dart:convert'; import 'package:one_trip/api/auth.dart'; import 'package:one_trip/api/consts.dart'; import 'package:http/http.dart' as http; +import 'package:one_trip/api/searchresult.dart'; -class SearchResult { - List users; - String? next; +// class SearchResult { +// List users; +// String? next; - SearchResult({required this.users, required this.next}); -} +// SearchResult({required this.users, required this.next}); +// } class SimpleUser { int id; @@ -38,7 +39,8 @@ class SimpleUser { } static Future get({int? id}) async { - String requestURL = "$baseURL/auth/users/${id ?? 'me'}"; + String requestURL = + id == null ? "$baseURL/auth/users/me" : "$baseURL/auth/users/$id/"; String token = TokenSingleton().getToken(); final http.Response response = await http.get( @@ -54,7 +56,7 @@ class SimpleUser { } } - static Future search(String query, int page) async { + static Future> search(String query, int page) async { // String requestURL = ""; // if (url != null) { // requestURL = url; @@ -81,9 +83,10 @@ class SimpleUser { users.add(u); } - return SearchResult(users: users, next: json["next"] as String?); + return SearchResult( + results: users, next: json["next"] as String?); } - return SearchResult(users: [], next: null); + return SearchResult(results: [], next: null); } } diff --git a/one_trip/lib/api/searchresult.dart b/one_trip/lib/api/searchresult.dart new file mode 100644 index 0000000..34ec5eb --- /dev/null +++ b/one_trip/lib/api/searchresult.dart @@ -0,0 +1,6 @@ +class SearchResult { + List results; + String? next; + + SearchResult({required this.results, required this.next}); +} diff --git a/one_trip/lib/pages/list_page/list_page.dart b/one_trip/lib/pages/list_page/list_page.dart index dd8dc6c..c944800 100644 --- a/one_trip/lib/pages/list_page/list_page.dart +++ b/one_trip/lib/pages/list_page/list_page.dart @@ -1,52 +1,305 @@ -// 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'; +import 'dart:convert'; +import 'dart:io'; -// class RecipesPage extends StatefulWidget { -// const RecipesPage({super.key}); +import 'package:flutter/material.dart'; +import 'package:one_trip/api/auth.dart'; +import 'package:one_trip/api/consts.dart'; +import 'package:one_trip/api/models/list.dart'; +import 'package:one_trip/api/models/listingredient.dart'; +import 'package:one_trip/api/models/user.dart'; +import 'package:one_trip/pages/list_page/widgets/listrow.dart'; +import 'package:one_trip/pages/list_page/widgets/search_recipes.dart'; +import 'package:one_trip/widgets/text_entry_dialog.dart'; -// @override -// State createState() => _RecipesPageState(); -// } +class ListPage extends StatefulWidget { + const ListPage({super.key}); -// class _RecipesPageState extends State { -// late Future> _recipes; -// late User _userInfo; + @override + State createState() => _ListPageState(); +} -// Future> _fetchList() async { -// User? userInfo = await User.getMe(); -// if (userInfo == null || userInfo.homegroup == null) { -// return []; -// } -// _userInfo = userInfo; +class _ListPageState extends State { + ShoppingList? _list; + late Future _isLoaded; + User? _userInfo; + WebSocket? _ws; -// List recipes = await Recipe.getList(_userInfo.homegroup!); -// return recipes; -// } + Future _fetchList() async { + User? userInfo = await User.getMe(); + _userInfo = userInfo; -// @override -// void initState() { -// super.initState(); -// _recipes = _fetchList(); -// } + if (userInfo == null || userInfo.homegroup == null) { + return false; + } -// @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 + _list = await ShoppingList.get(userInfo.homegroup!); + return true; + } + + void _connectSocket() async { + String token = TokenSingleton().getToken(); + _ws = await WebSocket.connect("$baseWsURL/ws/", + headers: {"Authorization": "Token $token"}); + + if (_ws == null) { + return; + } + + _ws!.listen((event) async { + Map json = jsonDecode(event); + + if (json.keys.contains("type") && json["type"] == "recommend_update") { + if (json["hash"] != _list.hashCode) { + ShoppingList? newList = await ShoppingList.get(_list!.homegroup); + + if (newList != null) { + setState(() { + _list = newList; + }); + } + } + } + }); + } + + void _sendUpdate() async { + if (_ws == null) { + return; + } + _ws!.add(jsonEncode({"type": "broadcast_update", "hash": _list.hashCode})); + } + + @override + void dispose() { + if (_ws != null) { + _ws!.close(); + } + super.dispose(); + } + + @override + void initState() { + super.initState(); + _isLoaded = _fetchList(); + _connectSocket(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _isLoaded, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Text(snapshot.error.toString()); + } else if (snapshot.hasData && + snapshot.connectionState == ConnectionState.done) { + 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 if (_list == null) { + return const Center( + child: Text("Issue loading list"), + ); + } else { + return ListArea( + list: _list!, + onAddOne: () async { + String? itemName = + await textEntryDialog(context, "Item Name", "Item"); + + if (itemName == null || itemName == "") { + return; + } + + ListIngredient? newIngredient = + await ListIngredient.create(itemName, _list!.homegroup); + if (newIngredient == null) { + return; + } + + ShoppingList? newList = + await ShoppingList.get(_list!.homegroup); + + if (newList != null) { + setState(() { + _list = newList; + }); + } + + _sendUpdate(); + }, + onAddMany: () async { + List? selectedIDs = await searchRecipesDialog(context); + + if (selectedIDs == null) { + return; + } + + ShoppingList tempList = _list!; + for (int id in selectedIDs) { + ShoppingList? newList = await tempList.addRecipe(id); + + if (newList != null) { + tempList = newList; + } + } + + setState(() { + _list = tempList; + }); + + _sendUpdate(); + }, + onDelete: (ingredient) async { + bool success = await ingredient.delete(); + if (success) { + // ShoppingList? newList = + // await _list!.patch(updates: _list!.updates + 1); + + ShoppingList? newList = + await ShoppingList.get(_list!.homegroup); + + setState(() { + _list = newList; + }); + + _sendUpdate(); + } + + return success; + }, + onUpdate: (ingredient, {inCart, name}) async { + ListIngredient? updated = + await ingredient.patch(name: name, inCart: inCart); + if (updated != null) { + ShoppingList? newList = + await ShoppingList.get(_list!.homegroup); + + setState(() { + _list = newList; + }); + + _sendUpdate(); + } + }, + onClear: () async { + ShoppingList? newList = await _list!.clear(); + + if (newList != null) { + setState(() { + _list = newList; + }); + } + + _sendUpdate(); + }, + ); + } + } else { + return const Center(child: CircularProgressIndicator()); + } + }, + ); + } +} + +class ListArea extends StatelessWidget { + final ShoppingList list; + final Function() onAddOne; + final Function() onAddMany; + final Function() onClear; + final Future Function(ListIngredient ingredient) onDelete; + final Function(ListIngredient ingredient, {String? name, bool? inCart}) + onUpdate; + const ListArea({ + super.key, + required this.list, + required this.onAddOne, + required this.onAddMany, + required this.onDelete, + required this.onUpdate, + required this.onClear, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: ListView.separated( + key: UniqueKey(), + itemCount: list.ingredients.length, + padding: const EdgeInsets.all(8), + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, index) { + return ListRow( + ingredient: list.ingredients[index], + onToggle: (value) { + onUpdate(list.ingredients[index], inCart: value); + }, + apiRemove: (ingredient) async => await onDelete(ingredient), + index: index, + ); + }, + ), + ), + LayoutBuilder( + builder: (context, constraints) { + ButtonStyle buttonStyle = ButtonStyle( + fixedSize: MaterialStatePropertyAll( + Size(constraints.maxWidth / 3, 45), + ), + shape: MaterialStateProperty.all( + const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + bottom: Radius.zero, top: Radius.circular(10)), + ), + ), + padding: const MaterialStatePropertyAll(EdgeInsets.zero)); + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ElevatedButton( + style: buttonStyle, + onPressed: () => onAddMany(), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: const [Icon(Icons.post_add), Text("Add Recipes")], + ), + ), + ElevatedButton( + style: buttonStyle, + onPressed: () => onAddOne(), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: const [Icon(Icons.add), Text("Add Item")], + ), + ), + ElevatedButton( + style: buttonStyle.copyWith( + backgroundColor: MaterialStatePropertyAll( + Theme.of(context).colorScheme.error), + foregroundColor: MaterialStatePropertyAll( + Theme.of(context).colorScheme.onError), + ), + onPressed: () => onClear(), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: const [Icon(Icons.delete), Text("Clear List")], + ), + ) + ], + ); + }, + ) + ], + ); + } +} diff --git a/one_trip/lib/pages/list_page/widgets/listrow.dart b/one_trip/lib/pages/list_page/widgets/listrow.dart new file mode 100644 index 0000000..f00c536 --- /dev/null +++ b/one_trip/lib/pages/list_page/widgets/listrow.dart @@ -0,0 +1,85 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:one_trip/api/models/listingredient.dart'; + +class ListRow extends StatefulWidget { + final ListIngredient ingredient; + final Future Function(ListIngredient ingredient) apiRemove; + final Function(bool value) onToggle; + final int index; + const ListRow({ + super.key, + required this.ingredient, + required this.onToggle, + required this.index, + required this.apiRemove, + }); + + @override + State createState() => _ListRowState(); +} + +class _ListRowState extends State { + double dismissAmount = 0.0; + bool willDismiss = false; + final UniqueKey key = UniqueKey(); + + @override + void didUpdateWidget(covariant ListRow oldWidget) { + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => widget.onToggle(!widget.ingredient.inCart), + child: Dismissible( + key: key, + direction: DismissDirection.endToStart, + onUpdate: (details) => setState(() { + dismissAmount = details.progress; + willDismiss = details.reached; + }), + confirmDismiss: (direction) async => + await widget.apiRemove(widget.ingredient), + background: Container( + color: Color.lerp( + Colors.transparent, Colors.red, min(dismissAmount * 2.5, 1)), + child: Align( + alignment: Alignment.centerRight, + child: SizedBox( + width: 45, + child: Icon( + Icons.delete, + size: min(27.5 * dismissAmount + 20, 35), + color: Colors.white, + ), + ), + ), + ), + child: Row( + children: [ + Checkbox( + value: widget.ingredient.inCart, + onChanged: (value) => widget.onToggle(value!), + ), + Expanded( + child: Text( + // _ingredient.name, + widget.ingredient.name, + style: Theme.of(context).textTheme.titleMedium!.copyWith( + decoration: widget.ingredient.inCart + ? TextDecoration.lineThrough + : null, + color: widget.ingredient.inCart ? Colors.green : null, + ), + ), + ), + // IconButton(onPressed: () {}, icon: const Icon(Icons.edit)), + ], + ), + ), + ); + } +} diff --git a/one_trip/lib/pages/list_page/widgets/search_recipes.dart b/one_trip/lib/pages/list_page/widgets/search_recipes.dart new file mode 100644 index 0000000..4d8bd08 --- /dev/null +++ b/one_trip/lib/pages/list_page/widgets/search_recipes.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:one_trip/api/models/recipe.dart'; +import 'package:one_trip/api/searchresult.dart'; +import 'package:one_trip/theme.dart'; +import 'package:one_trip/widgets/pagination_listview.dart'; + +class SearchRecipesDialog extends StatefulWidget { + const SearchRecipesDialog({super.key}); + + @override + State createState() => _SearchRecipesDialogState(); +} + +class _SearchRecipesDialogState extends State { + final TextEditingController _searchController = TextEditingController(); + ListViewState _listState = ListViewState.inactive; + List selectedIDs = []; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Search your Recipes", + style: Theme.of(context).textTheme.titleMedium, + ), + const Divider(), + TextFormField( + controller: _searchController, + textInputAction: TextInputAction.search, + onFieldSubmitted: (value) { + setState(() { + _listState = ListViewState.changed; + }); + }, + onChanged: (value) { + setState(() { + _listState = ListViewState.inactive; + }); + }, + decoration: InputDecoration( + label: const Text("Search"), + isDense: true, + suffix: IconButton( + onPressed: () { + setState(() { + _listState = ListViewState.changed; + }); + + // https://flutterigniter.com/dismiss-keyboard-form-lose-focus/ + FocusScopeNode currentFocus = FocusScope.of(context); + if (!currentFocus.hasPrimaryFocus) { + currentFocus.unfocus(); + } + }, + icon: const Icon(Icons.search), + ), + ), + ), + const SizedBox(height: 8), + LayoutBuilder( + builder: (builder, constraints) { + return ConstrainedBox( + constraints: BoxConstraints.expand( + width: constraints.maxWidth - 8, + height: 160, + ), + child: PaginationListView( + state: _listState, + shrinkWrap: true, + itemBuilder: (context, data) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + setState(() { + _listState = ListViewState.inUse; + if (selectedIDs.contains(data.id)) { + selectedIDs.remove(data.id); + } else { + selectedIDs.add(data.id); + } + }); + }, + child: Container( + padding: const EdgeInsets.all(4), + color: selectedIDs.contains(data.id) + ? Theme.of(context).colorScheme.secondary + : null, + child: Text(data.name), + ), + ); + }, + seperatorBuilder: (context, data) { + return const Divider(); + }, + dataProvider: (int page) async { + // SearchResult result = + // await SimpleUser.search(_searchController.text, page); + // List users = List.from(result.results); + + // if (result.next == null) { + // users.add(null); + // } + + // return users; + + SearchResult result = + await Recipe.search(_searchController.text, page); + List recipes = + List.from(result.results); + + if (result.next == null) { + recipes.add(null); + } + + return recipes; + }, + ), + ); + }, + ), + Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton( + onPressed: () => Navigator.pop(context), + child: const Text("Cancel"), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, selectedIDs), + child: const Text("Done")), + ], + ), + ) + ], + ), + ), + ); + } +} + +Future?> searchRecipesDialog(BuildContext context) async { + List? selectedIDs = await showDialog( + context: context, + builder: (context) { + return Dialog( + child: ScrollConfiguration( + behavior: MyBehavior(), child: const SearchRecipesDialog()), + ); + }, + ); + + return selectedIDs; +} 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 50be508..76cd102 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 @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:one_trip/api/models/simpleuser.dart'; +import 'package:one_trip/api/searchresult.dart'; import 'package:one_trip/pages/profile_page/widgets/user_chip.dart'; import 'package:one_trip/theme.dart'; import 'package:one_trip/widgets/pagination_listview.dart'; @@ -118,9 +119,9 @@ class _InviteHomegroupDialogState extends State { return const Divider(); }, dataProvider: (int page) async { - SearchResult result = + SearchResult result = await SimpleUser.search(_searchController.text, page); - List users = List.from(result.users); + List users = List.from(result.results); if (result.next == null) { users.add(null); diff --git a/one_trip/lib/pages/recipes_page/recipes_page.dart b/one_trip/lib/pages/recipes_page/recipes_page.dart index f617a9d..9193650 100644 --- a/one_trip/lib/pages/recipes_page/recipes_page.dart +++ b/one_trip/lib/pages/recipes_page/recipes_page.dart @@ -23,7 +23,7 @@ class _RecipesPageState extends State { return []; } - List recipes = await Recipe.getList(userInfo.homegroup!); + List recipes = await Recipe.getList(); return recipes; } @@ -106,47 +106,44 @@ class _RecipeListState extends State { Widget build(BuildContext context) { return Stack( children: [ - ListView.separated( + Scrollbar( 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: _recipes[index], - isExpanded: _expandedCard == index, - onTap: () { - setState(() { - if (_expandedCard == index) { - _expandedCard = null; - } else { - _expandedCard = index; - } - }); - }, - 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) { + child: ListView.separated( + 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: _recipes[index], + isExpanded: _expandedCard == index, + onTap: () { setState(() { - _recipes[index] = newRecipe; + if (_expandedCard == index) { + _expandedCard = null; + } else { + _expandedCard = index; + } }); - } - }, + }, + onDismiss: () { + if (_expandedCard != null && _expandedCard! > index) { + _expandedCard = _expandedCard! - 1; + } + + setState(() { + _recipes.removeAt(index); + }); + }, + onChanged: () async { + Recipe? newRecipe = await Recipe.get(_recipes[index].id); + if (newRecipe != null) { + setState(() { + _recipes[index] = newRecipe; + }); + } + }, + ), ), ), Align( @@ -154,6 +151,7 @@ class _RecipeListState extends State { child: Padding( padding: const EdgeInsets.all(8.0), child: FloatingActionButton.extended( + heroTag: "add-ingredient", onPressed: () async { String? name = await textEntryDialog(context, "Recipe Name", "Recipe"); @@ -180,11 +178,11 @@ class _RecipeListState extends State { } }, label: Row( - children: const [Icon(Icons.note_add), Text("New Recipe")], + children: const [Icon(Icons.post_add), Text("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 ed0ec63..30db849 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 @@ -85,6 +85,7 @@ class _RecipeCardState extends State with TickerProviderStateMixin { : DismissDirection.endToStart, key: Key("${widget.recipe.id}"), onDismissed: (direction) => widget.onDismiss(), + confirmDismiss: (direction) => widget.recipe.delete(), onUpdate: (details) { setState(() { dismissAmount = details.progress; @@ -92,16 +93,8 @@ class _RecipeCardState extends State with TickerProviderStateMixin { }); }, 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), - ], - ), - ), + color: Color.lerp(Colors.transparent, Colors.red, + min(dismissAmount * 2.5, 1)), child: Align( alignment: Alignment.centerRight, child: SizedBox( @@ -109,7 +102,7 @@ class _RecipeCardState extends State with TickerProviderStateMixin { child: Icon( Icons.delete, size: min(27.5 * dismissAmount + 20, 35), - color: willDismiss ? Colors.red : Colors.white, + color: Colors.white, ), ), ), @@ -193,9 +186,11 @@ class _IngredientSectionState extends State { @override Widget build(BuildContext context) { - return Container( + return Material( + elevation: 10, color: Theme.of(context).colorScheme.surface, child: ListView.builder( + physics: const NeverScrollableScrollPhysics(), padding: widget.ingredients.isEmpty ? EdgeInsets.zero : const EdgeInsets.all(8), @@ -208,16 +203,8 @@ class _IngredientSectionState extends State { 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), - ], - ), - ), + color: Color.lerp(Colors.transparent, Colors.red, + min(dismissAmount * 2.5, 1)), child: Align( alignment: Alignment.centerRight, child: SizedBox( @@ -225,7 +212,7 @@ class _IngredientSectionState extends State { child: Icon( Icons.delete, size: min(27.5 * dismissAmount + 20, 35), - color: willDismiss ? Colors.red : Colors.white, + color: Colors.white, ), ), ), @@ -236,11 +223,10 @@ class _IngredientSectionState extends State { willDismiss = details.reached; }); }, + confirmDismiss: (direction) async => + await widget.ingredients[index].delete(), onDismissed: (direction) async { - bool success = await widget.ingredients[index].delete(); - if (success) { - widget.onChanged(); - } + widget.onChanged(); }, child: Row( crossAxisAlignment: CrossAxisAlignment.center, @@ -251,22 +237,23 @@ class _IngredientSectionState extends State { style: Theme.of(context).textTheme.titleMedium, )), IconButton( - onPressed: () async { - String? name = await textEntryDialog( - context, "Change Ingredient Name", "Ingredient", - defaultValue: widget.ingredients[index].name); + onPressed: () async { + String? name = await textEntryDialog( + context, "Change Ingredient Name", "Ingredient", + defaultValue: widget.ingredients[index].name); - if (name == null || name == "") { - return; - } + if (name == null || name == "") { + return; + } - RecipeIngredient? changed = - await widget.ingredients[index].patch(name); - if (changed != null) { - widget.onChanged(); - } - }, - icon: const Icon(Icons.edit)), + RecipeIngredient? changed = + await widget.ingredients[index].patch(name); + if (changed != null) { + widget.onChanged(); + } + }, + icon: const Icon(Icons.edit), + ), ], ), ), diff --git a/one_trip/lib/screens/home_screen.dart b/one_trip/lib/screens/home_screen.dart index 5c4bdd7..baca9de 100644 --- a/one_trip/lib/screens/home_screen.dart +++ b/one_trip/lib/screens/home_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:one_trip/pages/list_page/list_page.dart'; import 'package:one_trip/pages/profile_page/profile_page.dart'; import 'package:one_trip/pages/recipes_page/recipes_page.dart'; import 'package:one_trip/pages/themetest.dart'; @@ -23,7 +24,7 @@ class _HomeScreenState extends State { @override void initState() { _pages = [ - Container(), + const ListPage(), const RecipesPage(), const ProfilePage(), const ColorPage() diff --git a/one_trip/lib/theme.dart b/one_trip/lib/theme.dart index 0c9c6f2..1882547 100644 --- a/one_trip/lib/theme.dart +++ b/one_trip/lib/theme.dart @@ -12,10 +12,6 @@ final _lightScheme = final darkTheme = ThemeData( colorScheme: _darkScheme, toggleableActiveColor: _darkScheme.primary, - floatingActionButtonTheme: FloatingActionButtonThemeData( - backgroundColor: _darkScheme.primary, - splashColor: _darkScheme.secondary, - ), cardColor: _darkScheme.secondaryContainer); final lightTheme = ThemeData( @@ -31,6 +27,7 @@ final bottomButtonStyle = ButtonStyle( BorderRadius.vertical(top: Radius.zero, bottom: Radius.circular(10)), ), ), + elevation: const MaterialStatePropertyAll(10), ); // https://stackoverflow.com/a/51119796/13538080 diff --git a/one_trip/lib/widgets/pagination_listview.dart b/one_trip/lib/widgets/pagination_listview.dart index 74f3d39..0019979 100644 --- a/one_trip/lib/widgets/pagination_listview.dart +++ b/one_trip/lib/widgets/pagination_listview.dart @@ -6,16 +6,21 @@ class PaginationListView extends StatefulWidget { final Widget Function(BuildContext context, dynamic data) itemBuilder; final Widget Function(BuildContext context, dynamic data) seperatorBuilder; final bool? shrinkWrap; + final bool? prefetchOne; final ListViewState state; + final EdgeInsetsGeometry? padding; final Future> Function(int page) dataProvider; - const PaginationListView( - {super.key, - required this.itemBuilder, - required this.dataProvider, - required this.state, - required this.seperatorBuilder, - this.shrinkWrap}); + const PaginationListView({ + super.key, + required this.itemBuilder, + required this.dataProvider, + required this.state, + required this.seperatorBuilder, + this.prefetchOne, + this.shrinkWrap, + this.padding, + }); @override State createState() => _PaginationListViewState(); @@ -66,6 +71,15 @@ class _PaginationListViewState extends State { super.initState(); _scrollController = ScrollController(); _state = widget.state; + + if (widget.prefetchOne ?? false) { + _state = ListViewState.inUse; + _data = []; + _dataLeft = true; + _isLoading = false; + _pagesLoaded = 0; + consumeData(); + } } @override @@ -98,6 +112,7 @@ class _PaginationListViewState extends State { controller: _scrollController, itemCount: _data.length, shrinkWrap: widget.shrinkWrap ?? false, + padding: widget.padding, itemBuilder: (context, index) => widget.itemBuilder(context, _data[index]), separatorBuilder: (context, index) => diff --git a/one_trip/linux/my_application.cc b/one_trip/linux/my_application.cc index 8bd544d..c9e9c70 100644 --- a/one_trip/linux/my_application.cc +++ b/one_trip/linux/my_application.cc @@ -40,11 +40,11 @@ static void my_application_activate(GApplication* application) { if (use_header_bar) { GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "one_trip"); + gtk_header_bar_set_title(header_bar, "One Trip"); gtk_header_bar_set_show_close_button(header_bar, TRUE); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); } else { - gtk_window_set_title(window, "one_trip"); + gtk_window_set_title(window, "One Trip"); } gtk_window_set_default_size(window, 1280, 720); diff --git a/one_trip_api/api/apps.py b/one_trip_api/api/apps.py index 66656fd..f4187b4 100644 --- a/one_trip_api/api/apps.py +++ b/one_trip_api/api/apps.py @@ -1,5 +1,5 @@ from django.apps import AppConfig - +from django.db.models.signals import post_save class ApiConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' diff --git a/one_trip_api/api/migrations/0005_list_updates.py b/one_trip_api/api/migrations/0005_list_updates.py new file mode 100644 index 0000000..a02c66b --- /dev/null +++ b/one_trip_api/api/migrations/0005_list_updates.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.3 on 2022-11-29 16:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0004_remove_recipe_list_delete_ingredient'), + ] + + operations = [ + migrations.AddField( + model_name='list', + name='updates', + field=models.BigIntegerField(default=0), + ), + ] diff --git a/one_trip_api/api/models.py b/one_trip_api/api/models.py index 53dfe55..412d466 100644 --- a/one_trip_api/api/models.py +++ b/one_trip_api/api/models.py @@ -26,6 +26,7 @@ class Homegroup(models.Model): class List(models.Model): # Foreign Key ListIngredient -> List [as ingredients] homegroup = models.OneToOneField(Homegroup, on_delete=models.CASCADE, primary_key=True) + updates = models.BigIntegerField(default=0); class Recipe(models.Model): diff --git a/one_trip_api/api/serializers.py b/one_trip_api/api/serializers.py index 63a2c7f..811dfd3 100644 --- a/one_trip_api/api/serializers.py +++ b/one_trip_api/api/serializers.py @@ -1,6 +1,10 @@ from rest_framework import serializers from api.models import * from users.serializers import UserSerializer +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer + +channel_layer = get_channel_layer() class RecipeIngredientSerializer(serializers.ModelSerializer): class Meta: @@ -29,9 +33,13 @@ class ListSerializer(serializers.ModelSerializer): class Meta: model = List - fields = ["homegroup", "ingredients"] + fields = ["homegroup", "updates", "ingredients"] read_only_fields = ["homegroup"] + def update(self, instance, validated_data): + # async_to_sync(channel_layer.group_send)(f"group_{instance.homegroup.id}", {"type": "model_update"}) + return super().update(instance, validated_data) + def get_ingredients(self, instance): ingredients = instance.ingredients.all().order_by("name") return ListIngredientSerializer(ingredients, many=True).data diff --git a/one_trip_api/api/urls.py b/one_trip_api/api/urls.py index 4ed61d2..4694d17 100644 --- a/one_trip_api/api/urls.py +++ b/one_trip_api/api/urls.py @@ -3,7 +3,8 @@ from rest_framework import routers from api import views router = routers.DefaultRouter() -router.register(r'recipes', views.RecipeView) +router.register(r'recipes', views.RecipeAllView) +router.register(r'searchrecipes', views.RecipeSearchView) router.register(r'lists', views.ListView) router.register(r'recipeingredients', views.RecipeIngredientView) router.register(r'listingredients', views.ListIngredientView) diff --git a/one_trip_api/api/views.py b/one_trip_api/api/views.py index fd8f661..368b30e 100644 --- a/one_trip_api/api/views.py +++ b/one_trip_api/api/views.py @@ -1,28 +1,69 @@ -from rest_framework import viewsets, mixins, views, status, permissions +from rest_framework import viewsets, mixins, permissions, request, pagination, filters from rest_framework.response import Response +from rest_framework.request import Request from api.serializers import * from api.models import * -class RecipeView(viewsets.ModelViewSet): +class HasHomegroup(permissions.BasePermission): + def has_permission(self, request: Request, view): + if not request.user.homegroup: + return False + + return super().has_permission(request, view) + +class Pagination(pagination.PageNumberPagination): + page_size = 4 + +class NoListModelViewset(mixins.CreateModelMixin, mixins.DestroyModelMixin, mixins.UpdateModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): + pass + +class RecipeSearchView(viewsets.ModelViewSet): serializer_class = RecipeSerializer + permission_classes = [permissions.IsAuthenticated, HasHomegroup] queryset = Recipe.objects.all() + filter_backends = [filters.SearchFilter] + search_fields = ["name"] + pagination_class = Pagination + + def list(self, request: Request, *args, **kwargs): + queryset = self.filter_queryset(Recipe.objects.filter(homegroup=request.user.homegroup).order_by("name")); + + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.serializer_class(queryset, many=True) + return Response(serializer.data) + +class RecipeAllView(viewsets.ModelViewSet): + serializer_class = RecipeSerializer + permission_classes = [permissions.IsAuthenticated, HasHomegroup] + queryset = Recipe.objects.all() + filter_backends = [filters.SearchFilter] + search_fields = ["name"] + + def list(self, request: Request, *args, **kwargs): + queryset = self.filter_queryset(Recipe.objects.filter(homegroup=request.user.homegroup).order_by("name")); + serializer = self.serializer_class(queryset, many=True) + return Response(serializer.data) class HomegroupView(viewsets.ModelViewSet): serializer_class = HomegroupSerializer queryset = Homegroup.objects.all() -class HomegroupInviteView(viewsets.ModelViewSet): +class HomegroupInviteView(NoListModelViewset): serializer_class = InviteSerializer queryset = HomegroupInvite.objects.all() -class RecipeIngredientView(viewsets.ModelViewSet): +class RecipeIngredientView(NoListModelViewset): serializer_class = RecipeIngredientSerializer queryset = RecipeIngredient.objects.all() -class ListIngredientView(viewsets.ModelViewSet): +class ListIngredientView(NoListModelViewset): serializer_class = ListIngredientSerializer queryset = ListIngredient.objects.all() -class ListView(mixins.RetrieveModelMixin, viewsets.GenericViewSet): +class ListView(mixins.RetrieveModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet): serializer_class = ListSerializer queryset = List.objects.all() \ No newline at end of file diff --git a/one_trip_api/one_trip_api/asgi.py b/one_trip_api/one_trip_api/asgi.py index 73ae753..6218732 100644 --- a/one_trip_api/one_trip_api/asgi.py +++ b/one_trip_api/one_trip_api/asgi.py @@ -10,6 +10,9 @@ https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ import os from django.core.asgi import get_asgi_application +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.auth import AuthMiddlewareStack +import ws.routing settings = 'one_trip_api.settings.dev' if os.getenv("DJANGO_RELEASE", False): @@ -17,5 +20,10 @@ if os.getenv("DJANGO_RELEASE", False): os.environ.setdefault('DJANGO_SETTINGS_MODULE', settings) +print("ASGI Started") +django_asgi_app = get_asgi_application() -application = get_asgi_application() +application = ProtocolTypeRouter({ + "http": django_asgi_app, + "websocket": AuthMiddlewareStack(URLRouter(ws.routing.websocket_urlpatterns)) +}) diff --git a/one_trip_api/one_trip_api/settings/base.py b/one_trip_api/one_trip_api/settings/base.py index 63076ae..1dd979c 100644 --- a/one_trip_api/one_trip_api/settings/base.py +++ b/one_trip_api/one_trip_api/settings/base.py @@ -33,6 +33,8 @@ REST_FRAMEWORK = { INSTALLED_APPS = [ 'api', 'users', + 'ws', + 'daphne', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', @@ -76,6 +78,15 @@ TEMPLATES = [ ] WSGI_APPLICATION = 'one_trip_api.wsgi.application' +ASGI_APPLICATION = 'one_trip_api.asgi.application' +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [("127.0.0.1", 6379)], + }, + }, +} DATABASES = { 'default': { diff --git a/one_trip_api/one_trip_api/wsgi.py b/one_trip_api/one_trip_api/wsgi.py index 9f12603..1d49dc7 100644 --- a/one_trip_api/one_trip_api/wsgi.py +++ b/one_trip_api/one_trip_api/wsgi.py @@ -17,5 +17,5 @@ if os.getenv("DJANGO_RELEASE", False): os.environ.setdefault('DJANGO_SETTINGS_MODULE', settings) - +print("WSGI Started") application = get_wsgi_application() diff --git a/one_trip_api/users/middleware.py b/one_trip_api/users/middleware.py index 5f6fc56..86025b0 100644 --- a/one_trip_api/users/middleware.py +++ b/one_trip_api/users/middleware.py @@ -8,7 +8,7 @@ class ExemptCSRFMiddleware: def __call__(self, request): - if request.path_info == "/auth/token": + if request.path_info in ["/auth/token", "/auth/users/"]: setattr(request, '_dont_enforce_csrf_checks', True) response = self.get_response(request) diff --git a/one_trip_api/ws/__init__.py b/one_trip_api/ws/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/one_trip_api/ws/admin.py b/one_trip_api/ws/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/one_trip_api/ws/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/one_trip_api/ws/apps.py b/one_trip_api/ws/apps.py new file mode 100644 index 0000000..2c87a48 --- /dev/null +++ b/one_trip_api/ws/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class WsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'ws' diff --git a/one_trip_api/ws/consumers.py b/one_trip_api/ws/consumers.py new file mode 100644 index 0000000..fdf3046 --- /dev/null +++ b/one_trip_api/ws/consumers.py @@ -0,0 +1,51 @@ +from channels.db import database_sync_to_async +from channels.generic.websocket import AsyncJsonWebsocketConsumer +from rest_framework.authtoken.models import Token +from api.models import Homegroup +from users.models import User + +class ChatConsumer(AsyncJsonWebsocketConsumer): + async def connect(self): + token_homegroup = await self.get_homegroup_by_token(self.scope["headers"]) + if token_homegroup is None: + await self.disconnect(1) + else: + self.room_name = token_homegroup.id + self.room_group_name = f"group_{self.room_name}" + await self.channel_layer.group_add(self.room_group_name, self.channel_name) + await self.accept() + + + async def receive_json(self, content, **kwargs): + await self.channel_layer.group_send( + self.room_group_name, + content + ) + + async def disconnect(self, close_code): + await self.channel_layer.group_discard(self.room_group_name, self.channel_name) + + async def broadcast_update(self, event): + print(event) + await self.send_json(content={"type": "recommend_update", "hash": event["hash"]}) + + @database_sync_to_async + def get_homegroup_by_token(self, headers): + headers = self.scope["headers"] + for pair in headers: + if pair[0].decode("UTF-8") == "authorization": + tokenType, tokenString = pair[1].decode("UTF-8").split() + + queryset = Token.objects.filter(key=tokenString) + if queryset.exists(): + return Token.objects.get(key=tokenString).user.homegroup + else: + return None + + @database_sync_to_async + def get_homegroup_by_id(self, group_id): + queryset = Homegroup.objects.filter(id=group_id) + if queryset.exists(): + return queryset.get() + else: + return None diff --git a/one_trip_api/ws/migrations/__init__.py b/one_trip_api/ws/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/one_trip_api/ws/models.py b/one_trip_api/ws/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/one_trip_api/ws/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/one_trip_api/ws/routing.py b/one_trip_api/ws/routing.py new file mode 100644 index 0000000..5c678c9 --- /dev/null +++ b/one_trip_api/ws/routing.py @@ -0,0 +1,7 @@ +from django.urls import re_path, path + +from ws import consumers + +websocket_urlpatterns = [ + path('ws/', consumers.ChatConsumer.as_asgi(), name='room') +] \ No newline at end of file diff --git a/one_trip_api/ws/tests.py b/one_trip_api/ws/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/one_trip_api/ws/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/one_trip_api/ws/views.py b/one_trip_api/ws/views.py new file mode 100644 index 0000000..cf3d353 --- /dev/null +++ b/one_trip_api/ws/views.py @@ -0,0 +1,4 @@ +from django.shortcuts import render +from rest_framework.views import APIView + +# Create your views here. \ No newline at end of file