add websock functionality
This commit is contained in:
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 |
BIN
one_trip/assets/icons/adaptive.png
Normal file
BIN
one_trip/assets/icons/adaptive.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
@@ -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;
|
||||
|
||||
115
one_trip/lib/api/models/list.dart
Normal file
115
one_trip/lib/api/models/list.dart
Normal 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);
|
||||
}
|
||||
@@ -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<String, dynamic> json) {
|
||||
return RecipeIngredient(
|
||||
factory ListIngredient.fromJson(Map<String, dynamic> 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<RecipeIngredient?> create(String name, int recipeID) async {
|
||||
static Future<ListIngredient?> 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<RecipeIngredient?> patch(String name) async {
|
||||
Future<ListIngredient?> patch({String? name, bool? inCart}) async {
|
||||
String requestURL = "$baseURL/api/listingredients/$id/";
|
||||
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),
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<List<Recipe>> getList(int groupID) async {
|
||||
Homegroup? group = await Homegroup.get(groupID);
|
||||
if (group == null) {
|
||||
return [];
|
||||
}
|
||||
static Future<List<Recipe>> 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<Recipe> 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<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 {
|
||||
String requestURL = "$baseURL/api/recipes/";
|
||||
String token = TokenSingleton().getToken();
|
||||
|
||||
@@ -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<SimpleUser> users;
|
||||
String? next;
|
||||
// class SearchResult {
|
||||
// List<SimpleUser> 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<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();
|
||||
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 = "";
|
||||
// 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<SimpleUser>(
|
||||
results: users, next: json["next"] as String?);
|
||||
}
|
||||
|
||||
return SearchResult(users: [], next: null);
|
||||
return SearchResult<SimpleUser>(results: [], next: null);
|
||||
}
|
||||
}
|
||||
|
||||
6
one_trip/lib/api/searchresult.dart
Normal file
6
one_trip/lib/api/searchresult.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
class SearchResult<T> {
|
||||
List<T> results;
|
||||
String? next;
|
||||
|
||||
SearchResult({required this.results, required this.next});
|
||||
}
|
||||
@@ -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<RecipesPage> createState() => _RecipesPageState();
|
||||
// }
|
||||
class ListPage extends StatefulWidget {
|
||||
const ListPage({super.key});
|
||||
|
||||
// class _RecipesPageState extends State<RecipesPage> {
|
||||
// late Future<List<Recipe>> _recipes;
|
||||
// late User _userInfo;
|
||||
@override
|
||||
State<ListPage> createState() => _ListPageState();
|
||||
}
|
||||
|
||||
// Future<List<Recipe>> _fetchList() async {
|
||||
// User? userInfo = await User.getMe();
|
||||
// if (userInfo == null || userInfo.homegroup == null) {
|
||||
// return [];
|
||||
// }
|
||||
// _userInfo = userInfo;
|
||||
class _ListPageState extends State<ListPage> {
|
||||
ShoppingList? _list;
|
||||
late Future<bool> _isLoaded;
|
||||
User? _userInfo;
|
||||
WebSocket? _ws;
|
||||
|
||||
// List<Recipe> recipes = await Recipe.getList(_userInfo.homegroup!);
|
||||
// return recipes;
|
||||
// }
|
||||
Future<bool> _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());
|
||||
// }
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
_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<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")],
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
85
one_trip/lib/pages/list_page/widgets/listrow.dart
Normal file
85
one_trip/lib/pages/list_page/widgets/listrow.dart
Normal 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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
168
one_trip/lib/pages/list_page/widgets/search_recipes.dart
Normal file
168
one_trip/lib/pages/list_page/widgets/search_recipes.dart
Normal 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;
|
||||
}
|
||||
@@ -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<InviteHomegroupDialog> {
|
||||
return const Divider();
|
||||
},
|
||||
dataProvider: (int page) async {
|
||||
SearchResult result =
|
||||
SearchResult<SimpleUser> result =
|
||||
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) {
|
||||
users.add(null);
|
||||
|
||||
@@ -23,7 +23,7 @@ class _RecipesPageState extends State<RecipesPage> {
|
||||
return [];
|
||||
}
|
||||
|
||||
List<Recipe> recipes = await Recipe.getList(userInfo.homegroup!);
|
||||
List<Recipe> recipes = await Recipe.getList();
|
||||
return recipes;
|
||||
}
|
||||
|
||||
@@ -106,7 +106,9 @@ class _RecipeListState extends State<RecipeList> {
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
ListView.separated(
|
||||
Scrollbar(
|
||||
controller: _scrollController,
|
||||
child: ListView.separated(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
8, 8, 8, kFloatingActionButtonMargin + 48),
|
||||
@@ -124,17 +126,11 @@ class _RecipeListState extends State<RecipeList> {
|
||||
}
|
||||
});
|
||||
},
|
||||
onDismiss: () async {
|
||||
onDismiss: () {
|
||||
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);
|
||||
});
|
||||
@@ -149,11 +145,13 @@ class _RecipeListState extends State<RecipeList> {
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
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<RecipeList> {
|
||||
}
|
||||
},
|
||||
label: Row(
|
||||
children: const [Icon(Icons.note_add), Text("New Recipe")],
|
||||
children: const [Icon(Icons.post_add), Text("Recipe")],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ class _RecipeCardState extends State<RecipeCard> 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<RecipeCard> 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<RecipeCard> 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<IngredientSection> {
|
||||
|
||||
@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<IngredientSection> {
|
||||
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<IngredientSection> {
|
||||
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<IngredientSection> {
|
||||
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();
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
@@ -266,7 +252,8 @@ class _IngredientSectionState extends State<IngredientSection> {
|
||||
widget.onChanged();
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.edit)),
|
||||
icon: const Icon(Icons.edit),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<HomeScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
_pages = <Widget>[
|
||||
Container(),
|
||||
const ListPage(),
|
||||
const RecipesPage(),
|
||||
const ProfilePage(),
|
||||
const ColorPage()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<List<dynamic>> Function(int page) dataProvider;
|
||||
|
||||
const PaginationListView(
|
||||
{super.key,
|
||||
const PaginationListView({
|
||||
super.key,
|
||||
required this.itemBuilder,
|
||||
required this.dataProvider,
|
||||
required this.state,
|
||||
required this.seperatorBuilder,
|
||||
this.shrinkWrap});
|
||||
this.prefetchOne,
|
||||
this.shrinkWrap,
|
||||
this.padding,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PaginationListView> createState() => _PaginationListViewState();
|
||||
@@ -66,6 +71,15 @@ class _PaginationListViewState extends State<PaginationListView> {
|
||||
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<PaginationListView> {
|
||||
controller: _scrollController,
|
||||
itemCount: _data.length,
|
||||
shrinkWrap: widget.shrinkWrap ?? false,
|
||||
padding: widget.padding,
|
||||
itemBuilder: (context, index) =>
|
||||
widget.itemBuilder(context, _data[index]),
|
||||
separatorBuilder: (context, index) =>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'
|
||||
|
||||
18
one_trip_api/api/migrations/0005_list_updates.py
Normal file
18
one_trip_api/api/migrations/0005_list_updates.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -17,5 +17,5 @@ if os.getenv("DJANGO_RELEASE", False):
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', settings)
|
||||
|
||||
|
||||
print("WSGI Started")
|
||||
application = get_wsgi_application()
|
||||
|
||||
@@ -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)
|
||||
|
||||
0
one_trip_api/ws/__init__.py
Normal file
0
one_trip_api/ws/__init__.py
Normal file
3
one_trip_api/ws/admin.py
Normal file
3
one_trip_api/ws/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
one_trip_api/ws/apps.py
Normal file
6
one_trip_api/ws/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class WsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'ws'
|
||||
51
one_trip_api/ws/consumers.py
Normal file
51
one_trip_api/ws/consumers.py
Normal 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
|
||||
0
one_trip_api/ws/migrations/__init__.py
Normal file
0
one_trip_api/ws/migrations/__init__.py
Normal file
3
one_trip_api/ws/models.py
Normal file
3
one_trip_api/ws/models.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
7
one_trip_api/ws/routing.py
Normal file
7
one_trip_api/ws/routing.py
Normal 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
3
one_trip_api/ws/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
4
one_trip_api/ws/views.py
Normal file
4
one_trip_api/ws/views.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from django.shortcuts import render
|
||||
from rest_framework.views import APIView
|
||||
|
||||
# Create your views here.
|
||||
Reference in New Issue
Block a user