add websock functionality

This commit is contained in:
Alexander Laevens
2022-11-30 02:52:56 -07:00
parent 34edcd53cb
commit 339b0c6ad9
41 changed files with 1028 additions and 189 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -1,4 +1,5 @@
const String baseURL = "https://groceries.alaevens.ca"; // const String baseURL = "https://groceries.alaevens.ca";
// const String baseURL = "http://192.168.0.16:8000"; const String baseURL = "http://192.168.0.16:8000";
const String baseWsURL = "ws://192.168.0.16:8000";
const int resultsPerPage = 4; const int resultsPerPage = 4;

View File

@@ -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<ListIngredient> ingredients;
int updates;
int homegroup;
ShoppingList({
required this.ingredients,
required this.updates,
required this.homegroup,
});
factory ShoppingList.fromJson(Map<String, dynamic> json) {
List<ListIngredient> 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<ShoppingList?> 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<ShoppingList?> patch({int? updates}) async {
Map<String, String> 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<ShoppingList?> 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<ShoppingList?> 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);
}

View File

@@ -4,21 +4,21 @@ import 'package:one_trip/api/auth.dart';
import 'package:one_trip/api/consts.dart'; import 'package:one_trip/api/consts.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
class RecipeIngredient { class ListIngredient {
int id; int id;
String name; String name;
int list; int list;
bool inCart; bool inCart;
RecipeIngredient({ ListIngredient({
required this.id, required this.id,
required this.name, required this.name,
required this.list, required this.list,
required this.inCart, required this.inCart,
}); });
factory RecipeIngredient.fromJson(Map<String, dynamic> json) { factory ListIngredient.fromJson(Map<String, dynamic> json) {
return RecipeIngredient( return ListIngredient(
id: json["id"] as int, id: json["id"] as int,
name: json["name"] as String, name: json["name"] as String,
list: json["list"] as int, list: json["list"] as int,
@@ -26,7 +26,7 @@ class RecipeIngredient {
); );
} }
static Future<RecipeIngredient?> create(String name, int recipeID) async { static Future<ListIngredient?> create(String name, int list) async {
const String requestURL = "$baseURL/api/listingredients/"; const String requestURL = "$baseURL/api/listingredients/";
String token = TokenSingleton().getToken(); String token = TokenSingleton().getToken();
http.Response response = await http.post( http.Response response = await http.post(
@@ -34,26 +34,35 @@ class RecipeIngredient {
headers: {"Authorization": "Token $token"}, headers: {"Authorization": "Token $token"},
body: { body: {
"name": name, "name": name,
"recipe": "$recipeID", "list": "$list",
}, },
); );
if (response.statusCode == 201) { if (response.statusCode == 201) {
return RecipeIngredient.fromJson(jsonDecode(response.body)); return ListIngredient.fromJson(jsonDecode(response.body));
} else { } else {
return null; return null;
} }
} }
Future<RecipeIngredient?> patch(String name) async { Future<ListIngredient?> patch({String? name, bool? inCart}) async {
String requestURL = "$baseURL/api/listingredients/$id/"; String requestURL = "$baseURL/api/listingredients/$id/";
String token = TokenSingleton().getToken(); String token = TokenSingleton().getToken();
Map<String, String> body = {};
if (name != null) {
body["name"] = name;
}
if (inCart != null) {
body["in_cart"] = "$inCart";
}
http.Response response = await http.patch(Uri.parse(requestURL), 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) { if (response.statusCode == 200) {
return RecipeIngredient.fromJson(jsonDecode(response.body)); return ListIngredient.fromJson(jsonDecode(response.body));
} }
return null; return null;
@@ -71,4 +80,14 @@ class RecipeIngredient {
return false; 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);
} }

View File

@@ -2,9 +2,9 @@ import 'dart:convert';
import 'package:one_trip/api/auth.dart'; import 'package:one_trip/api/auth.dart';
import 'package:one_trip/api/consts.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:one_trip/api/models/recipeingredient.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:one_trip/api/searchresult.dart';
class Recipe { class Recipe {
int id; int id;
@@ -46,26 +46,51 @@ class Recipe {
return null; return null;
} }
static Future<List<Recipe>> getList(int groupID) async { static Future<List<Recipe>> getList() async {
Homegroup? group = await Homegroup.get(groupID); const String requestURL = "$baseURL/api/recipes/";
if (group == null) {
return []; String token = TokenSingleton().getToken();
} http.Response response = await http.get(
Uri.parse(requestURL),
headers: {"Authorization": "Token $token"},
);
List<Recipe> recipes = []; List<Recipe> recipes = [];
for (int recipeID in group.recipes) { if (response.statusCode == 200) {
Recipe? recipe = await Recipe.get(recipeID); var body = jsonDecode(response.body);
if (recipe != null) { for (var recipe in body) {
// TODO: implement sorted insert recipes.add(Recipe.fromJson(recipe));
recipes.add(recipe);
} }
} }
recipes.sort(((a, b) => a.name.compareTo(b.name)));
return recipes; return recipes;
} }
static Future<SearchResult<Recipe>> 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<String, dynamic> json = jsonDecode(response.body);
List<Recipe> recipes = [];
for (var recipeObject in json["results"]) {
Recipe r = Recipe.fromJson(recipeObject);
recipes.add(r);
}
return SearchResult<Recipe>(
results: recipes, next: json["next"] as String?);
}
return SearchResult<Recipe>(results: [], next: null);
}
static Future<Recipe?> create(String name, int group) async { static Future<Recipe?> create(String name, int group) async {
String requestURL = "$baseURL/api/recipes/"; String requestURL = "$baseURL/api/recipes/";
String token = TokenSingleton().getToken(); String token = TokenSingleton().getToken();

View File

@@ -3,13 +3,14 @@ import 'dart:convert';
import 'package:one_trip/api/auth.dart'; import 'package:one_trip/api/auth.dart';
import 'package:one_trip/api/consts.dart'; import 'package:one_trip/api/consts.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:one_trip/api/searchresult.dart';
class SearchResult { // class SearchResult {
List<SimpleUser> users; // List<SimpleUser> users;
String? next; // String? next;
SearchResult({required this.users, required this.next}); // SearchResult({required this.users, required this.next});
} // }
class SimpleUser { class SimpleUser {
int id; int id;
@@ -38,7 +39,8 @@ class SimpleUser {
} }
static Future<SimpleUser?> get({int? id}) async { static Future<SimpleUser?> 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(); String token = TokenSingleton().getToken();
final http.Response response = await http.get( final http.Response response = await http.get(
@@ -54,7 +56,7 @@ class SimpleUser {
} }
} }
static Future<SearchResult> search(String query, int page) async { static Future<SearchResult<SimpleUser>> search(String query, int page) async {
// String requestURL = ""; // String requestURL = "";
// if (url != null) { // if (url != null) {
// requestURL = url; // requestURL = url;
@@ -81,9 +83,10 @@ class SimpleUser {
users.add(u); users.add(u);
} }
return SearchResult(users: users, next: json["next"] as String?); return SearchResult<SimpleUser>(
results: users, next: json["next"] as String?);
} }
return SearchResult(users: [], next: null); return SearchResult<SimpleUser>(results: [], next: null);
} }
} }

View File

@@ -0,0 +1,6 @@
class SearchResult<T> {
List<T> results;
String? next;
SearchResult({required this.results, required this.next});
}

View File

@@ -1,52 +1,305 @@
// import 'package:flutter/material.dart'; import 'dart:convert';
// import 'package:one_trip/api/models/recipe.dart'; import 'dart:io';
// 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 { import 'package:flutter/material.dart';
// const RecipesPage({super.key}); 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 class ListPage extends StatefulWidget {
// State<RecipesPage> createState() => _RecipesPageState(); const ListPage({super.key});
// }
// class _RecipesPageState extends State<RecipesPage> { @override
// late Future<List<Recipe>> _recipes; State<ListPage> createState() => _ListPageState();
// late User _userInfo; }
// Future<List<Recipe>> _fetchList() async { class _ListPageState extends State<ListPage> {
// User? userInfo = await User.getMe(); ShoppingList? _list;
// if (userInfo == null || userInfo.homegroup == null) { late Future<bool> _isLoaded;
// return []; User? _userInfo;
// } WebSocket? _ws;
// _userInfo = userInfo;
// List<Recipe> recipes = await Recipe.getList(_userInfo.homegroup!); Future<bool> _fetchList() async {
// return recipes; User? userInfo = await User.getMe();
// } _userInfo = userInfo;
// @override if (userInfo == null || userInfo.homegroup == null) {
// void initState() { return false;
// super.initState(); }
// _recipes = _fetchList();
// }
// @override _list = await ShoppingList.get(userInfo.homegroup!);
// Widget build(BuildContext context) { return true;
// return FutureBuilder( }
// future: _recipes,
// builder: (context, snapshot) { void _connectSocket() async {
// if (snapshot.hasError) { String token = TokenSingleton().getToken();
// return Text(snapshot.error.toString()); _ws = await WebSocket.connect("$baseWsURL/ws/",
// } else if (snapshot.hasData && headers: {"Authorization": "Token $token"});
// snapshot.connectionState == ConnectionState.done) {
// return RecipeList( if (_ws == null) {
// recipes: snapshot.data!, homegroup: _userInfo.homegroup!); return;
// } else { }
// return const Center(child: CircularProgressIndicator());
// } _ws!.listen((event) async {
// }, Map<String, dynamic> 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<int>? 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<bool> 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")],
),
)
],
);
},
)
],
);
}
}

View File

@@ -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<bool> 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<ListRow> createState() => _ListRowState();
}
class _ListRowState extends State<ListRow> {
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)),
],
),
),
);
}
}

View File

@@ -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<SearchRecipesDialog> createState() => _SearchRecipesDialogState();
}
class _SearchRecipesDialogState extends State<SearchRecipesDialog> {
final TextEditingController _searchController = TextEditingController();
ListViewState _listState = ListViewState.inactive;
List<int> 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<SimpleUser> result =
// await SimpleUser.search(_searchController.text, page);
// List<dynamic> users = List<dynamic>.from(result.results);
// if (result.next == null) {
// users.add(null);
// }
// return users;
SearchResult<Recipe> result =
await Recipe.search(_searchController.text, page);
List<dynamic> recipes =
List<dynamic>.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<List<int>?> searchRecipesDialog(BuildContext context) async {
List<int>? selectedIDs = await showDialog(
context: context,
builder: (context) {
return Dialog(
child: ScrollConfiguration(
behavior: MyBehavior(), child: const SearchRecipesDialog()),
);
},
);
return selectedIDs;
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:one_trip/api/models/simpleuser.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/pages/profile_page/widgets/user_chip.dart';
import 'package:one_trip/theme.dart'; import 'package:one_trip/theme.dart';
import 'package:one_trip/widgets/pagination_listview.dart'; import 'package:one_trip/widgets/pagination_listview.dart';
@@ -118,9 +119,9 @@ class _InviteHomegroupDialogState extends State<InviteHomegroupDialog> {
return const Divider(); return const Divider();
}, },
dataProvider: (int page) async { dataProvider: (int page) async {
SearchResult result = SearchResult<SimpleUser> result =
await SimpleUser.search(_searchController.text, page); await SimpleUser.search(_searchController.text, page);
List<dynamic> users = List<dynamic>.from(result.users); List<dynamic> users = List<dynamic>.from(result.results);
if (result.next == null) { if (result.next == null) {
users.add(null); users.add(null);

View File

@@ -23,7 +23,7 @@ class _RecipesPageState extends State<RecipesPage> {
return []; return [];
} }
List<Recipe> recipes = await Recipe.getList(userInfo.homegroup!); List<Recipe> recipes = await Recipe.getList();
return recipes; return recipes;
} }
@@ -106,7 +106,9 @@ class _RecipeListState extends State<RecipeList> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Stack( return Stack(
children: [ children: [
ListView.separated( Scrollbar(
controller: _scrollController,
child: ListView.separated(
controller: _scrollController, controller: _scrollController,
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
8, 8, 8, kFloatingActionButtonMargin + 48), 8, 8, 8, kFloatingActionButtonMargin + 48),
@@ -124,17 +126,11 @@ class _RecipeListState extends State<RecipeList> {
} }
}); });
}, },
onDismiss: () async { onDismiss: () {
if (_expandedCard != null && _expandedCard! > index) { if (_expandedCard != null && _expandedCard! > index) {
_expandedCard = _expandedCard! - 1; _expandedCard = _expandedCard! - 1;
} }
bool success = await _recipes[index].delete();
if (!success) {
showError("Permanent deletion of recipe failed.");
}
setState(() { setState(() {
_recipes.removeAt(index); _recipes.removeAt(index);
}); });
@@ -149,11 +145,13 @@ class _RecipeListState extends State<RecipeList> {
}, },
), ),
), ),
),
Align( Align(
alignment: Alignment.bottomRight, alignment: Alignment.bottomRight,
child: Padding( child: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: FloatingActionButton.extended( child: FloatingActionButton.extended(
heroTag: "add-ingredient",
onPressed: () async { onPressed: () async {
String? name = String? name =
await textEntryDialog(context, "Recipe Name", "Recipe"); await textEntryDialog(context, "Recipe Name", "Recipe");
@@ -180,11 +178,11 @@ class _RecipeListState extends State<RecipeList> {
} }
}, },
label: Row( label: Row(
children: const [Icon(Icons.note_add), Text("New Recipe")], children: const [Icon(Icons.post_add), Text("Recipe")],
),
), ),
), ),
), ),
)
], ],
); );
} }

View File

@@ -85,6 +85,7 @@ class _RecipeCardState extends State<RecipeCard> with TickerProviderStateMixin {
: DismissDirection.endToStart, : DismissDirection.endToStart,
key: Key("${widget.recipe.id}"), key: Key("${widget.recipe.id}"),
onDismissed: (direction) => widget.onDismiss(), onDismissed: (direction) => widget.onDismiss(),
confirmDismiss: (direction) => widget.recipe.delete(),
onUpdate: (details) { onUpdate: (details) {
setState(() { setState(() {
dismissAmount = details.progress; dismissAmount = details.progress;
@@ -92,16 +93,8 @@ class _RecipeCardState extends State<RecipeCard> with TickerProviderStateMixin {
}); });
}, },
background: Container( background: Container(
decoration: const BoxDecoration( color: Color.lerp(Colors.transparent, Colors.red,
gradient: LinearGradient( min(dismissAmount * 2.5, 1)),
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Color.fromARGB(255, 255, 0, 0),
Color.fromARGB(255, 255, 170, 170),
],
),
),
child: Align( child: Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: SizedBox( child: SizedBox(
@@ -109,7 +102,7 @@ class _RecipeCardState extends State<RecipeCard> with TickerProviderStateMixin {
child: Icon( child: Icon(
Icons.delete, Icons.delete,
size: min(27.5 * dismissAmount + 20, 35), size: min(27.5 * dismissAmount + 20, 35),
color: willDismiss ? Colors.red : Colors.white, color: Colors.white,
), ),
), ),
), ),
@@ -193,9 +186,11 @@ class _IngredientSectionState extends State<IngredientSection> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Material(
elevation: 10,
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
child: ListView.builder( child: ListView.builder(
physics: const NeverScrollableScrollPhysics(),
padding: widget.ingredients.isEmpty padding: widget.ingredients.isEmpty
? EdgeInsets.zero ? EdgeInsets.zero
: const EdgeInsets.all(8), : const EdgeInsets.all(8),
@@ -208,16 +203,8 @@ class _IngredientSectionState extends State<IngredientSection> {
key: Key("${widget.ingredients[index].id}"), key: Key("${widget.ingredients[index].id}"),
direction: DismissDirection.endToStart, direction: DismissDirection.endToStart,
background: Container( background: Container(
decoration: const BoxDecoration( color: Color.lerp(Colors.transparent, Colors.red,
gradient: LinearGradient( min(dismissAmount * 2.5, 1)),
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Color.fromARGB(255, 255, 0, 0),
Color.fromARGB(255, 255, 170, 170),
],
),
),
child: Align( child: Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: SizedBox( child: SizedBox(
@@ -225,7 +212,7 @@ class _IngredientSectionState extends State<IngredientSection> {
child: Icon( child: Icon(
Icons.delete, Icons.delete,
size: min(27.5 * dismissAmount + 20, 35), size: min(27.5 * dismissAmount + 20, 35),
color: willDismiss ? Colors.red : Colors.white, color: Colors.white,
), ),
), ),
), ),
@@ -236,11 +223,10 @@ class _IngredientSectionState extends State<IngredientSection> {
willDismiss = details.reached; willDismiss = details.reached;
}); });
}, },
confirmDismiss: (direction) async =>
await widget.ingredients[index].delete(),
onDismissed: (direction) async { onDismissed: (direction) async {
bool success = await widget.ingredients[index].delete();
if (success) {
widget.onChanged(); widget.onChanged();
}
}, },
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
@@ -266,7 +252,8 @@ class _IngredientSectionState extends State<IngredientSection> {
widget.onChanged(); widget.onChanged();
} }
}, },
icon: const Icon(Icons.edit)), icon: const Icon(Icons.edit),
),
], ],
), ),
), ),

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; 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/profile_page/profile_page.dart';
import 'package:one_trip/pages/recipes_page/recipes_page.dart'; import 'package:one_trip/pages/recipes_page/recipes_page.dart';
import 'package:one_trip/pages/themetest.dart'; import 'package:one_trip/pages/themetest.dart';
@@ -23,7 +24,7 @@ class _HomeScreenState extends State<HomeScreen> {
@override @override
void initState() { void initState() {
_pages = <Widget>[ _pages = <Widget>[
Container(), const ListPage(),
const RecipesPage(), const RecipesPage(),
const ProfilePage(), const ProfilePage(),
const ColorPage() const ColorPage()

View File

@@ -12,10 +12,6 @@ final _lightScheme =
final darkTheme = ThemeData( final darkTheme = ThemeData(
colorScheme: _darkScheme, colorScheme: _darkScheme,
toggleableActiveColor: _darkScheme.primary, toggleableActiveColor: _darkScheme.primary,
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: _darkScheme.primary,
splashColor: _darkScheme.secondary,
),
cardColor: _darkScheme.secondaryContainer); cardColor: _darkScheme.secondaryContainer);
final lightTheme = ThemeData( final lightTheme = ThemeData(
@@ -31,6 +27,7 @@ final bottomButtonStyle = ButtonStyle(
BorderRadius.vertical(top: Radius.zero, bottom: Radius.circular(10)), BorderRadius.vertical(top: Radius.zero, bottom: Radius.circular(10)),
), ),
), ),
elevation: const MaterialStatePropertyAll(10),
); );
// https://stackoverflow.com/a/51119796/13538080 // https://stackoverflow.com/a/51119796/13538080

View File

@@ -6,16 +6,21 @@ class PaginationListView extends StatefulWidget {
final Widget Function(BuildContext context, dynamic data) itemBuilder; final Widget Function(BuildContext context, dynamic data) itemBuilder;
final Widget Function(BuildContext context, dynamic data) seperatorBuilder; final Widget Function(BuildContext context, dynamic data) seperatorBuilder;
final bool? shrinkWrap; final bool? shrinkWrap;
final bool? prefetchOne;
final ListViewState state; final ListViewState state;
final EdgeInsetsGeometry? padding;
final Future<List<dynamic>> Function(int page) dataProvider; final Future<List<dynamic>> Function(int page) dataProvider;
const PaginationListView( const PaginationListView({
{super.key, super.key,
required this.itemBuilder, required this.itemBuilder,
required this.dataProvider, required this.dataProvider,
required this.state, required this.state,
required this.seperatorBuilder, required this.seperatorBuilder,
this.shrinkWrap}); this.prefetchOne,
this.shrinkWrap,
this.padding,
});
@override @override
State<PaginationListView> createState() => _PaginationListViewState(); State<PaginationListView> createState() => _PaginationListViewState();
@@ -66,6 +71,15 @@ class _PaginationListViewState extends State<PaginationListView> {
super.initState(); super.initState();
_scrollController = ScrollController(); _scrollController = ScrollController();
_state = widget.state; _state = widget.state;
if (widget.prefetchOne ?? false) {
_state = ListViewState.inUse;
_data = [];
_dataLeft = true;
_isLoading = false;
_pagesLoaded = 0;
consumeData();
}
} }
@override @override
@@ -98,6 +112,7 @@ class _PaginationListViewState extends State<PaginationListView> {
controller: _scrollController, controller: _scrollController,
itemCount: _data.length, itemCount: _data.length,
shrinkWrap: widget.shrinkWrap ?? false, shrinkWrap: widget.shrinkWrap ?? false,
padding: widget.padding,
itemBuilder: (context, index) => itemBuilder: (context, index) =>
widget.itemBuilder(context, _data[index]), widget.itemBuilder(context, _data[index]),
separatorBuilder: (context, index) => separatorBuilder: (context, index) =>

View File

@@ -40,11 +40,11 @@ static void my_application_activate(GApplication* application) {
if (use_header_bar) { if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar)); 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_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else { } else {
gtk_window_set_title(window, "one_trip"); gtk_window_set_title(window, "One Trip");
} }
gtk_window_set_default_size(window, 1280, 720); gtk_window_set_default_size(window, 1280, 720);

View File

@@ -1,5 +1,5 @@
from django.apps import AppConfig from django.apps import AppConfig
from django.db.models.signals import post_save
class ApiConfig(AppConfig): class ApiConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'

View File

@@ -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),
),
]

View File

@@ -26,6 +26,7 @@ class Homegroup(models.Model):
class List(models.Model): class List(models.Model):
# Foreign Key ListIngredient -> List [as ingredients] # Foreign Key ListIngredient -> List [as ingredients]
homegroup = models.OneToOneField(Homegroup, on_delete=models.CASCADE, primary_key=True) homegroup = models.OneToOneField(Homegroup, on_delete=models.CASCADE, primary_key=True)
updates = models.BigIntegerField(default=0);
class Recipe(models.Model): class Recipe(models.Model):

View File

@@ -1,6 +1,10 @@
from rest_framework import serializers from rest_framework import serializers
from api.models import * from api.models import *
from users.serializers import UserSerializer 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 RecipeIngredientSerializer(serializers.ModelSerializer):
class Meta: class Meta:
@@ -29,9 +33,13 @@ class ListSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = List model = List
fields = ["homegroup", "ingredients"] fields = ["homegroup", "updates", "ingredients"]
read_only_fields = ["homegroup"] 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): def get_ingredients(self, instance):
ingredients = instance.ingredients.all().order_by("name") ingredients = instance.ingredients.all().order_by("name")
return ListIngredientSerializer(ingredients, many=True).data return ListIngredientSerializer(ingredients, many=True).data

View File

@@ -3,7 +3,8 @@ from rest_framework import routers
from api import views from api import views
router = routers.DefaultRouter() 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'lists', views.ListView)
router.register(r'recipeingredients', views.RecipeIngredientView) router.register(r'recipeingredients', views.RecipeIngredientView)
router.register(r'listingredients', views.ListIngredientView) router.register(r'listingredients', views.ListIngredientView)

View File

@@ -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.response import Response
from rest_framework.request import Request
from api.serializers import * from api.serializers import *
from api.models 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 serializer_class = RecipeSerializer
permission_classes = [permissions.IsAuthenticated, HasHomegroup]
queryset = Recipe.objects.all() 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): class HomegroupView(viewsets.ModelViewSet):
serializer_class = HomegroupSerializer serializer_class = HomegroupSerializer
queryset = Homegroup.objects.all() queryset = Homegroup.objects.all()
class HomegroupInviteView(viewsets.ModelViewSet): class HomegroupInviteView(NoListModelViewset):
serializer_class = InviteSerializer serializer_class = InviteSerializer
queryset = HomegroupInvite.objects.all() queryset = HomegroupInvite.objects.all()
class RecipeIngredientView(viewsets.ModelViewSet): class RecipeIngredientView(NoListModelViewset):
serializer_class = RecipeIngredientSerializer serializer_class = RecipeIngredientSerializer
queryset = RecipeIngredient.objects.all() queryset = RecipeIngredient.objects.all()
class ListIngredientView(viewsets.ModelViewSet): class ListIngredientView(NoListModelViewset):
serializer_class = ListIngredientSerializer serializer_class = ListIngredientSerializer
queryset = ListIngredient.objects.all() queryset = ListIngredient.objects.all()
class ListView(mixins.RetrieveModelMixin, viewsets.GenericViewSet): class ListView(mixins.RetrieveModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet):
serializer_class = ListSerializer serializer_class = ListSerializer
queryset = List.objects.all() queryset = List.objects.all()

View File

@@ -10,6 +10,9 @@ https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/
import os import os
from django.core.asgi import get_asgi_application 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' settings = 'one_trip_api.settings.dev'
if os.getenv("DJANGO_RELEASE", False): if os.getenv("DJANGO_RELEASE", False):
@@ -17,5 +20,10 @@ if os.getenv("DJANGO_RELEASE", False):
os.environ.setdefault('DJANGO_SETTINGS_MODULE', settings) 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))
})

View File

@@ -33,6 +33,8 @@ REST_FRAMEWORK = {
INSTALLED_APPS = [ INSTALLED_APPS = [
'api', 'api',
'users', 'users',
'ws',
'daphne',
'django.contrib.admin', 'django.contrib.admin',
'django.contrib.auth', 'django.contrib.auth',
'django.contrib.contenttypes', 'django.contrib.contenttypes',
@@ -76,6 +78,15 @@ TEMPLATES = [
] ]
WSGI_APPLICATION = 'one_trip_api.wsgi.application' 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 = { DATABASES = {
'default': { 'default': {

View File

@@ -17,5 +17,5 @@ if os.getenv("DJANGO_RELEASE", False):
os.environ.setdefault('DJANGO_SETTINGS_MODULE', settings) os.environ.setdefault('DJANGO_SETTINGS_MODULE', settings)
print("WSGI Started")
application = get_wsgi_application() application = get_wsgi_application()

View File

@@ -8,7 +8,7 @@ class ExemptCSRFMiddleware:
def __call__(self, request): 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) setattr(request, '_dont_enforce_csrf_checks', True)
response = self.get_response(request) response = self.get_response(request)

View File

3
one_trip_api/ws/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
one_trip_api/ws/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class WsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'ws'

View File

@@ -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

View File

View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View File

@@ -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')
]

3
one_trip_api/ws/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

4
one_trip_api/ws/views.py Normal file
View File

@@ -0,0 +1,4 @@
from django.shortcuts import render
from rest_framework.views import APIView
# Create your views here.