Compare commits
10 Commits
7653a1f0a5
...
b18f43a48b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b18f43a48b | ||
|
|
caf98b3e84 | ||
|
|
4d0388b262 | ||
|
|
31c4505e49 | ||
|
|
7fa947b1d7 | ||
|
|
df195634c0 | ||
|
|
7dd7abc09c | ||
|
|
2e7a306279 | ||
|
|
339b0c6ad9 | ||
|
|
34edcd53cb |
4
one_trip/.gitignore
vendored
@@ -42,3 +42,7 @@ app.*.map.json
|
|||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
|
||||||
|
OneTrip.AppDir
|
||||||
|
AppRun
|
||||||
|
**.AppImage
|
||||||
|
|||||||
8
one_trip/OneTrip.desktop
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Version=1.0
|
||||||
|
Type=Application
|
||||||
|
Terminal=false
|
||||||
|
Name=One Trip
|
||||||
|
Exec=one_trip %u
|
||||||
|
Icon=desktop
|
||||||
|
Categories=Utility;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="com.example.one_trip">
|
package="com.example.one_trip">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<application
|
<application
|
||||||
android:label="One Trip"
|
android:label="One Trip"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 17 KiB |
BIN
one_trip/assets/icons/adaptive.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
@@ -1,4 +1,6 @@
|
|||||||
// 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 baseWsURL = "wss://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;
|
const int resultsPerPage = 4;
|
||||||
|
|||||||
111
one_trip/lib/api/models/list.dart
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
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 homegroup;
|
||||||
|
|
||||||
|
ShoppingList({
|
||||||
|
required this.ingredients,
|
||||||
|
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, 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(
|
||||||
|
homegroup, ingredient.name, ingredient.quantity);
|
||||||
|
|
||||||
|
if (newIngredient != null) {
|
||||||
|
anySuccesses = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anySuccesses) {
|
||||||
|
return get(homegroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 get(homegroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is ShoppingList &&
|
||||||
|
other.homegroup == homegroup &&
|
||||||
|
other.ingredients == ingredients;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hashAll(ingredients);
|
||||||
|
}
|
||||||
@@ -4,56 +4,85 @@ 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;
|
||||||
|
String? quantity;
|
||||||
int list;
|
int list;
|
||||||
bool inCart;
|
bool inCart;
|
||||||
|
|
||||||
RecipeIngredient({
|
ListIngredient({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
|
required this.quantity,
|
||||||
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,
|
||||||
|
quantity: json["quantity"] as String?,
|
||||||
list: json["list"] as int,
|
list: json["list"] as int,
|
||||||
inCart: json["in_cart"] as bool,
|
inCart: json["in_cart"] as bool,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<RecipeIngredient?> create(String name, int recipeID) async {
|
static Future<ListIngredient?> create(
|
||||||
|
int list, String name, String? quantity) async {
|
||||||
const String requestURL = "$baseURL/api/listingredients/";
|
const String requestURL = "$baseURL/api/listingredients/";
|
||||||
String token = TokenSingleton().getToken();
|
String token = TokenSingleton().getToken();
|
||||||
|
|
||||||
|
Map<String, dynamic> body = {
|
||||||
|
"name": name,
|
||||||
|
"list": list,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (quantity != null) {
|
||||||
|
body["quantity"] = quantity;
|
||||||
|
}
|
||||||
|
|
||||||
http.Response response = await http.post(
|
http.Response response = await http.post(
|
||||||
Uri.parse(requestURL),
|
Uri.parse(requestURL),
|
||||||
headers: {"Authorization": "Token $token"},
|
headers: {
|
||||||
body: {
|
"Authorization": "Token $token",
|
||||||
"name": name,
|
"Content-Type": "application/json",
|
||||||
"recipe": "$recipeID",
|
|
||||||
},
|
},
|
||||||
|
body: jsonEncode(body),
|
||||||
);
|
);
|
||||||
|
|
||||||
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, String? quantity, 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, dynamic> body = {"quantity": quantity ?? this.quantity};
|
||||||
|
|
||||||
|
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",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: jsonEncode(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 +100,15 @@ class RecipeIngredient {
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is ListIngredient &&
|
||||||
|
other.id == id &&
|
||||||
|
other.name == name &&
|
||||||
|
other.quantity == quantity &&
|
||||||
|
other.inCart == inCart;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(id, name, quantity, inCart);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import 'package:http/http.dart' as http;
|
|||||||
class RecipeIngredient {
|
class RecipeIngredient {
|
||||||
int id;
|
int id;
|
||||||
String name;
|
String name;
|
||||||
|
String? quantity;
|
||||||
int recipe;
|
int recipe;
|
||||||
|
|
||||||
RecipeIngredient({
|
RecipeIngredient({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
|
required this.quantity,
|
||||||
required this.recipe,
|
required this.recipe,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -19,20 +21,32 @@ class RecipeIngredient {
|
|||||||
return RecipeIngredient(
|
return RecipeIngredient(
|
||||||
id: json["id"] as int,
|
id: json["id"] as int,
|
||||||
name: json["name"] as String,
|
name: json["name"] as String,
|
||||||
|
quantity: json["quantity"] as String?,
|
||||||
recipe: json["recipe"] as int,
|
recipe: json["recipe"] as int,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<RecipeIngredient?> create(String name, int recipeID) async {
|
static Future<RecipeIngredient?> create(
|
||||||
|
int recipeID, String name, String? quantity) async {
|
||||||
const String requestURL = "$baseURL/api/recipeingredients/";
|
const String requestURL = "$baseURL/api/recipeingredients/";
|
||||||
String token = TokenSingleton().getToken();
|
String token = TokenSingleton().getToken();
|
||||||
|
|
||||||
|
Map<String, dynamic> body = {
|
||||||
|
"name": name,
|
||||||
|
"recipe": recipeID,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (quantity != null) {
|
||||||
|
body["quantity"] = quantity;
|
||||||
|
}
|
||||||
|
|
||||||
http.Response response = await http.post(
|
http.Response response = await http.post(
|
||||||
Uri.parse(requestURL),
|
Uri.parse(requestURL),
|
||||||
headers: {"Authorization": "Token $token"},
|
headers: {
|
||||||
body: {
|
"Authorization": "Token $token",
|
||||||
"name": name,
|
"Content-Type": "application/json",
|
||||||
"recipe": "$recipeID",
|
|
||||||
},
|
},
|
||||||
|
body: jsonEncode(body),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 201) {
|
if (response.statusCode == 201) {
|
||||||
@@ -42,12 +56,22 @@ class RecipeIngredient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<RecipeIngredient?> patch(String name) async {
|
Future<RecipeIngredient?> patch({String? name, String? quantity}) async {
|
||||||
|
Map<String, dynamic> body = {"quantity": quantity};
|
||||||
|
|
||||||
|
if (name != null) {
|
||||||
|
body["name"] = name;
|
||||||
|
}
|
||||||
|
|
||||||
String requestURL = "$baseURL/api/recipeingredients/$id/";
|
String requestURL = "$baseURL/api/recipeingredients/$id/";
|
||||||
String token = TokenSingleton().getToken();
|
String token = TokenSingleton().getToken();
|
||||||
|
|
||||||
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",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: jsonEncode(body));
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
return RecipeIngredient.fromJson(jsonDecode(response.body));
|
return RecipeIngredient.fromJson(jsonDecode(response.body));
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -28,17 +29,21 @@ class SimpleUser {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory SimpleUser.fromJson(Map<String, dynamic> json) {
|
factory SimpleUser.fromJson(Map<String, dynamic> json) {
|
||||||
|
String? imagePath = json["image"] as String?;
|
||||||
|
String? imageUrl = imagePath != null ? "$baseURL/media/$imagePath" : null;
|
||||||
|
|
||||||
return SimpleUser(
|
return SimpleUser(
|
||||||
id: json["id"] as int,
|
id: json["id"] as int,
|
||||||
username: json["username"] as String,
|
username: json["username"] as String,
|
||||||
firstName: json["first_name"] as String,
|
firstName: json["first_name"] as String,
|
||||||
lastName: json["last_name"] as String,
|
lastName: json["last_name"] as String,
|
||||||
imageUrl: json["image"] as String?,
|
imageUrl: imageUrl,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +59,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 +86,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,13 +28,16 @@ class User {
|
|||||||
List<dynamic> invitesDynamic = json["homegroup_invites"];
|
List<dynamic> invitesDynamic = json["homegroup_invites"];
|
||||||
List<int> invites = invitesDynamic.map((e) => e as int).toList();
|
List<int> invites = invitesDynamic.map((e) => e as int).toList();
|
||||||
|
|
||||||
|
String? imagePath = json["image"] as String?;
|
||||||
|
String? imageUrl = imagePath != null ? "$baseURL/media/$imagePath" : null;
|
||||||
|
|
||||||
return User(
|
return User(
|
||||||
id: json["id"] as int,
|
id: json["id"] as int,
|
||||||
username: json["username"] as String,
|
username: json["username"] as String,
|
||||||
firstName: json["first_name"] as String,
|
firstName: json["first_name"] as String,
|
||||||
lastName: json["last_name"] as String,
|
lastName: json["last_name"] as String,
|
||||||
homegroup: json["homegroup"] as int?,
|
homegroup: json["homegroup"] as int?,
|
||||||
imageUrl: json["image"] as String?,
|
imageUrl: imageUrl,
|
||||||
homegroupInvites: invites,
|
homegroupInvites: invites,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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});
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ class MyApp extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'Grocery Helper',
|
title: 'One Trip',
|
||||||
theme: lightTheme,
|
theme: lightTheme,
|
||||||
darkTheme: darkTheme,
|
darkTheme: darkTheme,
|
||||||
themeMode: ThemeMode.system,
|
themeMode: ThemeMode.system,
|
||||||
|
|||||||
@@ -1,52 +1,313 @@
|
|||||||
// import 'package:flutter/material.dart';
|
import 'dart:convert';
|
||||||
// import 'package:one_trip/api/models/recipe.dart';
|
import 'package:flutter/material.dart';
|
||||||
// import 'package:one_trip/api/models/user.dart';
|
import 'package:one_trip/api/auth.dart';
|
||||||
// import 'package:one_trip/pages/recipes_page/widgets/recipe_card_widget.dart';
|
import 'package:one_trip/api/consts.dart';
|
||||||
// import 'package:one_trip/widgets/text_entry_dialog.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_dialog.dart';
|
||||||
|
import 'package:one_trip/widgets/confirm_dialog.dart';
|
||||||
|
import 'package:one_trip/widgets/ingredient_dialog.dart';
|
||||||
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
|
|
||||||
// class RecipesPage extends StatefulWidget {
|
class ListPage extends StatefulWidget {
|
||||||
// const RecipesPage({super.key});
|
const ListPage({super.key});
|
||||||
|
|
||||||
// @override
|
@override
|
||||||
// State<RecipesPage> createState() => _RecipesPageState();
|
State<ListPage> createState() => _ListPageState();
|
||||||
// }
|
}
|
||||||
|
|
||||||
// class _RecipesPageState extends State<RecipesPage> {
|
class _ListPageState extends State<ListPage> {
|
||||||
// late Future<List<Recipe>> _recipes;
|
ShoppingList? _list;
|
||||||
// late User _userInfo;
|
late Future<bool> _isLoaded;
|
||||||
|
User? _userInfo;
|
||||||
|
WebSocketChannel? _wsChannel;
|
||||||
|
|
||||||
// Future<List<Recipe>> _fetchList() async {
|
Future<bool> _fetchList() async {
|
||||||
// User? userInfo = await User.getMe();
|
User? userInfo = await User.getMe();
|
||||||
// if (userInfo == null || userInfo.homegroup == null) {
|
_userInfo = userInfo;
|
||||||
// return [];
|
|
||||||
// }
|
|
||||||
// _userInfo = userInfo;
|
|
||||||
|
|
||||||
// List<Recipe> recipes = await Recipe.getList(_userInfo.homegroup!);
|
if (userInfo == null || userInfo.homegroup == null) {
|
||||||
// return recipes;
|
return false;
|
||||||
// }
|
}
|
||||||
|
|
||||||
// @override
|
_list = await ShoppingList.get(userInfo.homegroup!);
|
||||||
// void initState() {
|
_connectSocket();
|
||||||
// super.initState();
|
return true;
|
||||||
// _recipes = _fetchList();
|
}
|
||||||
// }
|
|
||||||
|
|
||||||
// @override
|
void _connectSocket() async {
|
||||||
// Widget build(BuildContext context) {
|
String token = TokenSingleton().getToken();
|
||||||
// return FutureBuilder(
|
_wsChannel = WebSocketChannel.connect(
|
||||||
// future: _recipes,
|
Uri.parse("$baseWsURL/ws/?authorization=$token"));
|
||||||
// builder: (context, snapshot) {
|
_wsChannel!.stream.listen(
|
||||||
// if (snapshot.hasError) {
|
(event) async {
|
||||||
// return Text(snapshot.error.toString());
|
Map<String, dynamic> json = jsonDecode(event);
|
||||||
// } else if (snapshot.hasData &&
|
|
||||||
// snapshot.connectionState == ConnectionState.done) {
|
if (json.keys.contains("type") && json["type"] == "recommend_update") {
|
||||||
// return RecipeList(
|
if (json["hash"] != _list.hashCode) {
|
||||||
// recipes: snapshot.data!, homegroup: _userInfo.homegroup!);
|
ShoppingList? newList = await ShoppingList.get(_list!.homegroup);
|
||||||
// } else {
|
|
||||||
// return const Center(child: CircularProgressIndicator());
|
if (newList != null) {
|
||||||
// }
|
setState(() {
|
||||||
// },
|
_list = newList;
|
||||||
// );
|
});
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// ignore: avoid_print
|
||||||
|
onError: (error) => print("Websocket error: $error"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sendUpdate() async {
|
||||||
|
if (_wsChannel == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_wsChannel!.sink
|
||||||
|
.add(jsonEncode({"type": "broadcast_update", "hash": _list.hashCode}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
if (_wsChannel != null) {
|
||||||
|
_wsChannel!.sink.close();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_isLoaded = _fetchList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 {
|
||||||
|
IngredientDetails? details =
|
||||||
|
await ingredientDialog(context, "", "");
|
||||||
|
|
||||||
|
if (details == null || details.name == "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ListIngredient? newIngredient = await ListIngredient.create(
|
||||||
|
_list!.homegroup,
|
||||||
|
details.name,
|
||||||
|
details.quantity != "" ? details.quantity : null);
|
||||||
|
|
||||||
|
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: () async {
|
||||||
|
bool doDelete = await confirmDialog(context, "Clear List");
|
||||||
|
if (doDelete) {
|
||||||
|
onClear();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: const [Icon(Icons.delete), Text("Clear List")],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
87
one_trip/lib/pages/list_page/widgets/listrow.dart
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
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.quantity == null
|
||||||
|
? widget.ingredient.name
|
||||||
|
: "${widget.ingredient.name} - ${widget.ingredient.quantity}",
|
||||||
|
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_dialog.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,
|
||||||
|
prefetchOne: 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,
|
||||||
|
style: TextStyle(
|
||||||
|
color: selectedIDs.contains(data.id)
|
||||||
|
? Theme.of(context).colorScheme.onSecondary
|
||||||
|
: null),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
seperatorBuilder: (context, data) {
|
||||||
|
return const Divider();
|
||||||
|
},
|
||||||
|
dataProvider: (int page) async {
|
||||||
|
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(
|
||||||
|
style: negativeButtonStyle(context),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text("Cancel"),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: positiveButtonStyle(context),
|
||||||
|
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: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);
|
||||||
@@ -138,12 +139,15 @@ class _InviteHomegroupDialogState extends State<InviteHomegroupDialog> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
|
style: negativeButtonStyle(context),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text("Cancel"),
|
child: const Text("Cancel"),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => Navigator.pop(context, selectedIDs),
|
style: positiveButtonStyle(context),
|
||||||
child: const Text("Done")),
|
onPressed: () => Navigator.pop(context, selectedIDs),
|
||||||
|
child: const Text("Done"),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,47 +106,44 @@ class _RecipeListState extends State<RecipeList> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
ListView.separated(
|
Scrollbar(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.fromLTRB(
|
child: ListView.separated(
|
||||||
8, 8, 8, kFloatingActionButtonMargin + 48),
|
controller: _scrollController,
|
||||||
itemCount: _recipes.length,
|
padding: const EdgeInsets.fromLTRB(
|
||||||
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
8, 8, 8, kFloatingActionButtonMargin + 48),
|
||||||
itemBuilder: (context, index) => RecipeCard(
|
itemCount: _recipes.length,
|
||||||
recipe: _recipes[index],
|
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||||
isExpanded: _expandedCard == index,
|
itemBuilder: (context, index) => RecipeCard(
|
||||||
onTap: () {
|
recipe: _recipes[index],
|
||||||
setState(() {
|
isExpanded: _expandedCard == index,
|
||||||
if (_expandedCard == index) {
|
onTap: () {
|
||||||
_expandedCard = null;
|
|
||||||
} else {
|
|
||||||
_expandedCard = index;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onDismiss: () async {
|
|
||||||
if (_expandedCard != null && _expandedCard! > index) {
|
|
||||||
_expandedCard = _expandedCard! - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool success = await _recipes[index].delete();
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
showError("Permanent deletion of recipe failed.");
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_recipes.removeAt(index);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onChanged: () async {
|
|
||||||
Recipe? newRecipe = await Recipe.get(_recipes[index].id);
|
|
||||||
if (newRecipe != null) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_recipes[index] = newRecipe;
|
if (_expandedCard == index) {
|
||||||
|
_expandedCard = null;
|
||||||
|
} else {
|
||||||
|
_expandedCard = index;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
},
|
onDismiss: () {
|
||||||
|
if (_expandedCard != null && _expandedCard! > index) {
|
||||||
|
_expandedCard = _expandedCard! - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_recipes.removeAt(index);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onChanged: () async {
|
||||||
|
Recipe? newRecipe = await Recipe.get(_recipes[index].id);
|
||||||
|
if (newRecipe != null) {
|
||||||
|
setState(() {
|
||||||
|
_recipes[index] = newRecipe;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Align(
|
Align(
|
||||||
@@ -154,6 +151,7 @@ class _RecipeListState extends State<RecipeList> {
|
|||||||
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")],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:one_trip/api/models/recipeingredient.dart';
|
import 'package:one_trip/api/models/recipeingredient.dart';
|
||||||
import 'package:one_trip/api/models/recipe.dart';
|
import 'package:one_trip/api/models/recipe.dart';
|
||||||
import 'package:one_trip/theme.dart';
|
import 'package:one_trip/theme.dart';
|
||||||
import 'package:one_trip/widgets/text_entry_dialog.dart';
|
import 'package:one_trip/widgets/ingredient_dialog.dart';
|
||||||
|
|
||||||
class RecipeCard extends StatefulWidget {
|
class RecipeCard extends StatefulWidget {
|
||||||
final Recipe recipe;
|
final Recipe recipe;
|
||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -152,15 +145,18 @@ class _RecipeCardState extends State<RecipeCard> with TickerProviderStateMixin {
|
|||||||
shape: const MaterialStatePropertyAll(
|
shape: const MaterialStatePropertyAll(
|
||||||
RoundedRectangleBorder())),
|
RoundedRectangleBorder())),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
String? name = await textEntryDialog(
|
IngredientDetails? details =
|
||||||
context, "Ingredient Name", "Ingredient");
|
await ingredientDialog(context, "", "");
|
||||||
|
|
||||||
if (name == null || name == "") {
|
if (details == null || details.name == "") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
RecipeIngredient? ingredient =
|
RecipeIngredient? ingredient =
|
||||||
await RecipeIngredient.create(name, widget.recipe.id);
|
await RecipeIngredient.create(
|
||||||
|
widget.recipe.id,
|
||||||
|
details.name,
|
||||||
|
details.quantity != "" ? details.quantity : null);
|
||||||
if (ingredient != null) {
|
if (ingredient != null) {
|
||||||
widget.onChanged();
|
widget.onChanged();
|
||||||
}
|
}
|
||||||
@@ -193,9 +189,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 +206,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 +215,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,42 +226,51 @@ 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();
|
widget.onChanged();
|
||||||
if (success) {
|
|
||||||
widget.onChanged();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
widget.ingredients[index].name,
|
widget.ingredients[index].quantity == null
|
||||||
|
? widget.ingredients[index].name
|
||||||
|
: "${widget.ingredients[index].name} - ${widget.ingredients[index].quantity}",
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
)),
|
)),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
String? name = await textEntryDialog(
|
IngredientDetails? details = await ingredientDialog(
|
||||||
context, "Change Ingredient Name", "Ingredient",
|
context,
|
||||||
defaultValue: widget.ingredients[index].name);
|
widget.ingredients[index].name,
|
||||||
|
widget.ingredients[index].quantity ?? "");
|
||||||
|
|
||||||
if (name == null || name == "") {
|
if (details == null || details.name == "") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
RecipeIngredient? changed =
|
RecipeIngredient? changed =
|
||||||
await widget.ingredients[index].patch(name);
|
await widget.ingredients[index].patch(
|
||||||
if (changed != null) {
|
name: details.name,
|
||||||
widget.onChanged();
|
quantity: details.quantity != ""
|
||||||
}
|
? details.quantity
|
||||||
},
|
: null);
|
||||||
icon: const Icon(Icons.edit)),
|
|
||||||
|
if (changed != null) {
|
||||||
|
widget.onChanged();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.edit),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Divider(
|
Divider(
|
||||||
height: 1,
|
height: 2,
|
||||||
|
thickness: 1,
|
||||||
color: index % 2 == 0
|
color: index % 2 == 0
|
||||||
? Theme.of(context).colorScheme.primary
|
? Theme.of(context).colorScheme.primary
|
||||||
: Theme.of(context).colorScheme.error)
|
: Theme.of(context).colorScheme.error)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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';
|
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
@@ -17,16 +17,16 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
"Shopping List",
|
"Shopping List",
|
||||||
"Saved Recipes",
|
"Saved Recipes",
|
||||||
"Your Profile",
|
"Your Profile",
|
||||||
"Color Debug"
|
// "Color Debug"
|
||||||
];
|
];
|
||||||
|
|
||||||
@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()
|
||||||
];
|
];
|
||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
@@ -57,11 +57,11 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
label: "Profile",
|
label: "Profile",
|
||||||
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
|
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
|
||||||
),
|
),
|
||||||
BottomNavigationBarItem(
|
// BottomNavigationBarItem(
|
||||||
icon: const Icon(Icons.grid_3x3),
|
// icon: const Icon(Icons.grid_3x3),
|
||||||
label: "Colors",
|
// label: "Colors",
|
||||||
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
|
// backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
|
||||||
)
|
// )
|
||||||
],
|
],
|
||||||
currentIndex: _selectedPage,
|
currentIndex: _selectedPage,
|
||||||
onTap: (value) {
|
onTap: (value) {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -41,3 +38,28 @@ class MyBehavior extends ScrollBehavior {
|
|||||||
return child;
|
return child;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ButtonStyle positiveButtonStyle(BuildContext context) {
|
||||||
|
Brightness brightness = Theme.of(context).colorScheme.brightness;
|
||||||
|
|
||||||
|
if (brightness == Brightness.dark) {
|
||||||
|
return ButtonStyle(
|
||||||
|
backgroundColor: MaterialStatePropertyAll(Colors.green[200]),
|
||||||
|
foregroundColor: MaterialStatePropertyAll(Colors.green[900]),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return ButtonStyle(
|
||||||
|
backgroundColor: MaterialStatePropertyAll(Colors.green[900]),
|
||||||
|
foregroundColor: const MaterialStatePropertyAll(Colors.white),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ButtonStyle negativeButtonStyle(BuildContext context) {
|
||||||
|
return ButtonStyle(
|
||||||
|
backgroundColor:
|
||||||
|
MaterialStatePropertyAll(Theme.of(context).colorScheme.error),
|
||||||
|
foregroundColor:
|
||||||
|
MaterialStatePropertyAll(Theme.of(context).colorScheme.onError),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
67
one_trip/lib/widgets/confirm_dialog.dart
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:one_trip/theme.dart';
|
||||||
|
|
||||||
|
class ConfirmForm extends StatefulWidget {
|
||||||
|
final String title;
|
||||||
|
const ConfirmForm({super.key, required this.title});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ConfirmForm> createState() => _ConfirmFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ConfirmFormState extends State<ConfirmForm> {
|
||||||
|
@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(
|
||||||
|
widget.title,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
const Text("This action is permanent. Do you want to continue?"),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
ElevatedButton(
|
||||||
|
style: positiveButtonStyle(context),
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text("Go Back"),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: negativeButtonStyle(context),
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text("Continue"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> confirmDialog(BuildContext context, String title) async {
|
||||||
|
bool? value = await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return Dialog(
|
||||||
|
child: ConfirmForm(
|
||||||
|
title: title,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return value ?? false;
|
||||||
|
}
|
||||||
122
one_trip/lib/widgets/ingredient_dialog.dart
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:one_trip/theme.dart';
|
||||||
|
|
||||||
|
class IngredientDetails {
|
||||||
|
String name;
|
||||||
|
String quantity;
|
||||||
|
|
||||||
|
IngredientDetails({required this.name, required this.quantity});
|
||||||
|
}
|
||||||
|
|
||||||
|
class IngredientForm extends StatefulWidget {
|
||||||
|
final String nameStartingValue;
|
||||||
|
final String quantityStartingValue;
|
||||||
|
const IngredientForm(
|
||||||
|
{super.key,
|
||||||
|
required this.nameStartingValue,
|
||||||
|
required this.quantityStartingValue});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<IngredientForm> createState() => _IngredientFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _IngredientFormState extends State<IngredientForm> {
|
||||||
|
late TextEditingController _nameController;
|
||||||
|
late TextEditingController _quantityController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameController.dispose();
|
||||||
|
_quantityController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_nameController = TextEditingController(text: widget.nameStartingValue);
|
||||||
|
_quantityController =
|
||||||
|
TextEditingController(text: widget.quantityStartingValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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(
|
||||||
|
"Add / Edit Ingredient",
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
TextFormField(
|
||||||
|
autofocus: true,
|
||||||
|
controller: _nameController,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
// onFieldSubmitted: (value) {
|
||||||
|
// Navigator.pop(context, value);
|
||||||
|
// },
|
||||||
|
decoration: const InputDecoration(hintText: "Name"),
|
||||||
|
),
|
||||||
|
TextFormField(
|
||||||
|
controller: _quantityController,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onFieldSubmitted: (value) => Navigator.pop(
|
||||||
|
context,
|
||||||
|
IngredientDetails(
|
||||||
|
name: _nameController.text,
|
||||||
|
quantity: _quantityController.text),
|
||||||
|
),
|
||||||
|
decoration: const InputDecoration(hintText: "Quantity"),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
ElevatedButton(
|
||||||
|
style: negativeButtonStyle(context),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text("Cancel"),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: positiveButtonStyle(context),
|
||||||
|
onPressed: () => Navigator.pop(
|
||||||
|
context,
|
||||||
|
IngredientDetails(
|
||||||
|
name: _nameController.text,
|
||||||
|
quantity: _quantityController.text),
|
||||||
|
),
|
||||||
|
child: const Text("Done"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<IngredientDetails?> ingredientDialog(
|
||||||
|
BuildContext context, String currentName, String currentQuantity) async {
|
||||||
|
IngredientDetails? details = await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return Dialog(
|
||||||
|
child: IngredientForm(
|
||||||
|
nameStartingValue: currentName,
|
||||||
|
quantityStartingValue: currentQuantity,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return details;
|
||||||
|
}
|
||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:one_trip/theme.dart';
|
||||||
|
|
||||||
class TextEntryForm extends StatefulWidget {
|
class TextEntryForm extends StatefulWidget {
|
||||||
final String title;
|
final String title;
|
||||||
@@ -55,13 +56,16 @@ class _TextEntryFormState extends State<TextEntryForm> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
|
style: negativeButtonStyle(context),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text("Cancel"),
|
child: const Text("Cancel"),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () =>
|
style: positiveButtonStyle(context),
|
||||||
Navigator.pop(context, _textController.text),
|
onPressed: () =>
|
||||||
child: const Text("Done")),
|
Navigator.pop(context, _textController.text),
|
||||||
|
child: const Text("Done"),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -392,6 +392,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.2"
|
||||||
|
web_socket_channel:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: web_socket_channel
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
xml:
|
xml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ dependencies:
|
|||||||
flutter_svg_provider: ^1.0.3
|
flutter_svg_provider: ^1.0.3
|
||||||
image_picker: ^0.8.6
|
image_picker: ^0.8.6
|
||||||
flutter_launcher_icons: ^0.11.0
|
flutter_launcher_icons: ^0.11.0
|
||||||
|
web_socket_channel: ^2.2.0
|
||||||
|
|
||||||
|
|
||||||
# The following adds the Cupertino Icons font to your application.
|
# The following adds the Cupertino Icons font to your application.
|
||||||
|
|||||||
7
one_trip_api/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
**/.git
|
||||||
|
**/dev_data
|
||||||
|
**/prod_data
|
||||||
|
**/venv
|
||||||
|
**/__pycache__
|
||||||
|
**/db.sqlite3
|
||||||
|
**/nginx.conf
|
||||||
2
one_trip_api/.gitignore
vendored
@@ -9,7 +9,7 @@ __pycache__/
|
|||||||
local_settings.py
|
local_settings.py
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
db.sqlite3-journal
|
db.sqlite3-journal
|
||||||
media
|
dev_data/
|
||||||
|
|
||||||
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
|
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
|
||||||
# in your Git repository. Update and uncomment the following line accordingly.
|
# in your Git repository. Update and uncomment the following line accordingly.
|
||||||
|
|||||||
41
one_trip_api/Dockerfile
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Set up user
|
||||||
|
ARG UID
|
||||||
|
ARG GID
|
||||||
|
RUN useradd --system --uid ${UID} --gid ${GID} --create-home --shell /bin/bash groceries
|
||||||
|
RUN usermod -aG ${GID} groceries
|
||||||
|
|
||||||
|
ARG DEBIAN_FRONTEND="noninteractive"
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV DJANGO_RELEASE=1
|
||||||
|
|
||||||
|
# Set up directories
|
||||||
|
ENV HOME=/home/groceries
|
||||||
|
ENV APP_DIR=${HOME}/web
|
||||||
|
ENV DATA_DIR=${HOME}/data
|
||||||
|
|
||||||
|
RUN mkdir -p ${APP_DIR}
|
||||||
|
RUN mkdir -p ${DATA_DIR}
|
||||||
|
|
||||||
|
WORKDIR ${APP_DIR}
|
||||||
|
|
||||||
|
RUN apt-get update
|
||||||
|
RUN apt-get install --yes --no-install-recommends wget
|
||||||
|
|
||||||
|
# Build pip requirements
|
||||||
|
ADD ./requirements.txt .
|
||||||
|
RUN pip install --upgrade pip
|
||||||
|
RUN pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Copy files
|
||||||
|
ADD . ${APP_DIR}
|
||||||
|
RUN chown -R ${UID}:${GID} ${HOME}
|
||||||
|
RUN chmod +x entrypoint.sh
|
||||||
|
|
||||||
|
USER groceries
|
||||||
|
|
||||||
|
ENTRYPOINT [ "/home/groceries/web/entrypoint.sh" ]
|
||||||
|
|
||||||
|
|
||||||
11
one_trip_api/Server deployment.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
1. sudo useradd --system --shell /bin/bash groceries
|
||||||
|
2. sudo usermod -aG www-data groceries
|
||||||
|
3. id -u groceries
|
||||||
|
4. id -g www-data
|
||||||
|
5. edit docker-compose.yaml to have correct UID / GID
|
||||||
|
6. docker compose build
|
||||||
|
7. sudo mkdir -p /var/www/groceries.alaevens.ca/data
|
||||||
|
8. sudo cp EXISTING_DB_FILE /var/www/groceries.alaevens.ca/db.sqlite3
|
||||||
|
9. sudo chown -R groceries /var/www/groceries.alaevens.ca/
|
||||||
|
10. sudo chgrp -R www-data /var/www/groceries.alaevens.ca/
|
||||||
|
11. docker compose up -d
|
||||||
@@ -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'
|
||||||
|
|||||||
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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Generated by Django 4.1.3 on 2022-12-06 22:34
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('api', '0005_list_updates'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='list',
|
||||||
|
name='updates',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='listingredient',
|
||||||
|
name='quantity',
|
||||||
|
field=models.CharField(blank=True, max_length=50, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='recipeingredient',
|
||||||
|
name='quantity',
|
||||||
|
field=models.CharField(blank=True, max_length=50, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -36,9 +36,11 @@ class Recipe(models.Model):
|
|||||||
|
|
||||||
class RecipeIngredient(models.Model):
|
class RecipeIngredient(models.Model):
|
||||||
name = models.CharField(max_length=50)
|
name = models.CharField(max_length=50)
|
||||||
|
quantity = models.CharField(max_length=50, null=True, blank=True)
|
||||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, related_name="ingredients")
|
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, related_name="ingredients")
|
||||||
|
|
||||||
class ListIngredient(models.Model):
|
class ListIngredient(models.Model):
|
||||||
name = models.CharField(max_length=50)
|
name = models.CharField(max_length=50)
|
||||||
|
quantity = models.CharField(max_length=50, null=True, blank=True)
|
||||||
list = models.ForeignKey(List, on_delete=models.CASCADE, related_name="ingredients")
|
list = models.ForeignKey(List, on_delete=models.CASCADE, related_name="ingredients")
|
||||||
in_cart = models.BooleanField(default=False)
|
in_cart = models.BooleanField(default=False)
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
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:
|
||||||
model = RecipeIngredient
|
model = RecipeIngredient
|
||||||
fields = ["id", "name", "recipe"]
|
fields = ["id", "name", "quantity", "recipe"]
|
||||||
|
|
||||||
class ListIngredientSerializer(serializers.ModelSerializer):
|
class ListIngredientSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ListIngredient
|
model = ListIngredient
|
||||||
fields = ["id", "name", "list", "in_cart"]
|
fields = ["id", "name", "quantity", "list", "in_cart"]
|
||||||
|
|
||||||
class RecipeSerializer(serializers.ModelSerializer):
|
class RecipeSerializer(serializers.ModelSerializer):
|
||||||
ingredients = serializers.SerializerMethodField()
|
ingredients = serializers.SerializerMethodField()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 = 10
|
||||||
|
|
||||||
|
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()
|
||||||
16
one_trip_api/daphne.service
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=daphne daemon
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=django
|
||||||
|
Group=www-data
|
||||||
|
Environment="DJANGO_RELEASE=True"
|
||||||
|
WorkingDirectory=/opt/django/OneTrip/one_trip_api
|
||||||
|
ExecStart=/opt/django/OneTrip/one_trip_api/venv/bin/daphne \
|
||||||
|
--u /run/daphne/daphne.sock \
|
||||||
|
one_trip_api.asgi:application
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
||||||
26
one_trip_api/docker-compose.yaml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
services:
|
||||||
|
groceries:
|
||||||
|
container_name: groceries-api
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
- UID=992
|
||||||
|
- GID=33
|
||||||
|
ports:
|
||||||
|
- 8001:8080
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- /var/www/groceries.alaevens.ca/data:/home/groceries/data:rw
|
||||||
|
- type: bind
|
||||||
|
source: /var/www/groceries.alaevens.ca/db.sqlite3
|
||||||
|
target: /home/groceries/web/db.sqlite3
|
||||||
|
|
||||||
|
redis:
|
||||||
|
container_name: groceries-ws-cache
|
||||||
|
image: "redis:alpine"
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
|
|
||||||
9
one_trip_api/entrypoint.sh
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
#! /bin/bash
|
||||||
|
printf "Make migrations:\n"
|
||||||
|
python3 manage.py makemigrations
|
||||||
|
printf "\n\nMigrate:\n"
|
||||||
|
python3 manage.py migrate
|
||||||
|
printf "\n\nCollect static:\n"
|
||||||
|
python3 manage.py collectstatic --no-input
|
||||||
|
printf "\n\nStart ASGI server:\n"
|
||||||
|
gunicorn one_trip_api.asgi:application -b 0.0.0.0:8080 --access-logfile - -k uvicorn.workers.UvicornWorker
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=gunicorn daemon
|
|
||||||
Requires=gunicorn.socket
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
User=django
|
|
||||||
Group=www-data
|
|
||||||
Environment="DJANGO_RELEASE=True"
|
|
||||||
WorkingDirectory=/opt/django/OneTrip/one_trip_api
|
|
||||||
ExecStart=/opt/django/OneTrip/one_trip_api/venv/bin/gunicorn \
|
|
||||||
--access-logfile - \
|
|
||||||
--workers 3 \
|
|
||||||
--bind unix:/run/gunicorn.sock \
|
|
||||||
one_trip_api.wsgi:application
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=gunicorn socket
|
|
||||||
|
|
||||||
[Socket]
|
|
||||||
ListenStream=/run/gunicorn.sock
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=sockets.target
|
|
||||||
59
one_trip_api/nginx.conf
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
server {
|
||||||
|
server_name groceries.alaevens.ca;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_pass http://container;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /downloads/ {
|
||||||
|
autoindex on;
|
||||||
|
alias /var/www/groceries.alaevens.ca/downloads/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /static/ {
|
||||||
|
alias /var/www/groceries.alaevens.ca/data/static/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /media/ {
|
||||||
|
alias /var/www/groceries.alaevens.ca/data/media/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /app/ {
|
||||||
|
alias /var/www/groceries.alaevens.ca/web_app/;
|
||||||
|
index index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
listen 443 ssl; # managed by Certbot
|
||||||
|
ssl_certificate /etc/letsencrypt/live/alaevens.ca/fullchain.pem; # managed by Certbot
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/alaevens.ca/privkey.pem; # managed by Certbot
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
if ($host = groceries.alaevens.ca) {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
} # managed by Certbot
|
||||||
|
|
||||||
|
|
||||||
|
server_name groceries.alaevens.ca;
|
||||||
|
|
||||||
|
listen [::]:80;
|
||||||
|
listen 80;
|
||||||
|
return 404; # managed by Certbot
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream container {
|
||||||
|
server 127.0.0.1:8001;
|
||||||
|
}
|
||||||
@@ -8,14 +8,24 @@ https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import django
|
||||||
from django.core.asgi import get_asgi_application
|
|
||||||
|
|
||||||
settings = 'one_trip_api.settings.dev'
|
settings = 'one_trip_api.settings.dev'
|
||||||
if os.getenv("DJANGO_RELEASE", False):
|
if os.getenv("DJANGO_RELEASE", False):
|
||||||
settings = 'one_trip_api.settings.release'
|
settings = 'one_trip_api.settings.release'
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', settings)
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', settings)
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||||
|
from channels.auth import AuthMiddlewareStack
|
||||||
|
import ws.routing
|
||||||
|
|
||||||
application = get_asgi_application()
|
print("ASGI Started")
|
||||||
|
django_asgi_app = get_asgi_application()
|
||||||
|
|
||||||
|
application = ProtocolTypeRouter({
|
||||||
|
"http": django_asgi_app,
|
||||||
|
"websocket": AuthMiddlewareStack(URLRouter(ws.routing.websocket_urlpatterns))
|
||||||
|
})
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||||
STATIC_URL = "static/"
|
STATIC_URL = "/static/"
|
||||||
MEDIA_URL = "/media/"
|
MEDIA_URL = "/media/"
|
||||||
|
|
||||||
|
|
||||||
@@ -33,6 +33,7 @@ REST_FRAMEWORK = {
|
|||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
'api',
|
'api',
|
||||||
'users',
|
'users',
|
||||||
|
'ws',
|
||||||
'django.contrib.admin',
|
'django.contrib.admin',
|
||||||
'django.contrib.auth',
|
'django.contrib.auth',
|
||||||
'django.contrib.contenttypes',
|
'django.contrib.contenttypes',
|
||||||
@@ -46,6 +47,7 @@ INSTALLED_APPS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
'users.middleware.ExemptCSRFMiddleware',
|
||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
'corsheaders.middleware.CorsMiddleware',
|
'corsheaders.middleware.CorsMiddleware',
|
||||||
@@ -75,6 +77,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": [("redis", 6379)],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
'default': {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ DEBUG = True
|
|||||||
|
|
||||||
SECRET_KEY = 'django-insecure-tz%&(g*jikac%ogq%vaf&%i!6m99q_lshu9g-&sz&bw8x!&zk3'
|
SECRET_KEY = 'django-insecure-tz%&(g*jikac%ogq%vaf&%i!6m99q_lshu9g-&sz&bw8x!&zk3'
|
||||||
|
|
||||||
MEDIA_ROOT = BASE_DIR.joinpath("media")
|
DATA_ROOT = BASE_DIR.joinpath("dev_data")
|
||||||
|
MEDIA_ROOT = DATA_ROOT.joinpath("media/")
|
||||||
|
STATIC_ROOT = DATA_ROOT.joinpath("static/")
|
||||||
ALLOWED_HOSTS = ["*"]
|
ALLOWED_HOSTS = ["*"]
|
||||||
CORS_ALLOW_ALL_ORIGINS = True
|
CORS_ALLOW_ALL_ORIGINS = True
|
||||||
@@ -5,11 +5,11 @@ print("USING RELEASE SETTINGS")
|
|||||||
|
|
||||||
SECRET_KEY = 'django-insecure-tz%&(g*jikac%ogq%vaf&%i!6m99q_lshu9g-&sz&bw8x!&zk3'
|
SECRET_KEY = 'django-insecure-tz%&(g*jikac%ogq%vaf&%i!6m99q_lshu9g-&sz&bw8x!&zk3'
|
||||||
|
|
||||||
DATA_ROOT = Path("/opt/django/data").resolve()
|
DATA_ROOT = Path("/home/groceries/data/").resolve()
|
||||||
MEDIA_ROOT = DATA_ROOT.joinpath("media/")
|
MEDIA_ROOT = DATA_ROOT.joinpath("media/")
|
||||||
STATIC_ROOT = DATA_ROOT.joinpath("static/")
|
STATIC_ROOT = DATA_ROOT.joinpath("static/")
|
||||||
|
|
||||||
ALLOWED_HOSTS = ["groceries.alaevens.ca"]
|
ALLOWED_HOSTS = ["groceries.alaevens.ca", "127.0.0.1", "0.0.0.0"]
|
||||||
|
|
||||||
if not MEDIA_ROOT.is_dir():
|
if not MEDIA_ROOT.is_dir():
|
||||||
os.makedirs(MEDIA_ROOT.as_posix())
|
os.makedirs(MEDIA_ROOT.as_posix())
|
||||||
|
|||||||
@@ -17,10 +17,14 @@ from django.contrib import admin
|
|||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
path('api/', include("api.urls")),
|
path('api/', include("api.urls")),
|
||||||
path('auth/', include("users.urls")),
|
path('auth/', include("users.urls")),
|
||||||
path('api-auth/', include('rest_framework.urls')),
|
path('api-auth/', include('rest_framework.urls')),
|
||||||
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
]
|
||||||
|
|
||||||
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
urlpatterns += staticfiles_urlpatterns()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -1,21 +1,39 @@
|
|||||||
asgiref==3.5.2
|
anyio==4.3.0
|
||||||
|
asgiref==3.7.2
|
||||||
|
async-timeout==4.0.2
|
||||||
certifi==2022.9.24
|
certifi==2022.9.24
|
||||||
|
channels==4.0.0
|
||||||
|
channels-redis==4.0.0
|
||||||
charset-normalizer==2.1.1
|
charset-normalizer==2.1.1
|
||||||
|
click==8.1.7
|
||||||
Django==4.1.3
|
Django==4.1.3
|
||||||
django-cors-headers==3.13.0
|
django-cors-headers==3.13.0
|
||||||
django-filter==22.1
|
django-filter==22.1
|
||||||
django-nested-admin==4.0.2
|
django-nested-admin==4.0.2
|
||||||
djangorestframework==3.14.0
|
djangorestframework==3.14.0
|
||||||
docopt==0.6.2
|
docopt==0.6.2
|
||||||
gunicorn==20.1.0
|
gunicorn==21.2.0
|
||||||
idna==3.4
|
h11==0.14.0
|
||||||
|
httptools==0.6.1
|
||||||
|
idna==3.6
|
||||||
Markdown==3.4.1
|
Markdown==3.4.1
|
||||||
|
msgpack==1.0.4
|
||||||
|
packaging==21.3
|
||||||
Pillow==9.3.0
|
Pillow==9.3.0
|
||||||
pipreqs==0.4.11
|
pipreqs==0.4.11
|
||||||
|
pyparsing==3.0.9
|
||||||
|
python-dotenv==1.0.1
|
||||||
python-monkey-business==1.0.0
|
python-monkey-business==1.0.0
|
||||||
pytz==2022.6
|
pytz==2022.6
|
||||||
|
PyYAML==6.0.1
|
||||||
|
redis==4.3.5
|
||||||
requests==2.28.1
|
requests==2.28.1
|
||||||
six==1.16.0
|
six==1.16.0
|
||||||
|
sniffio==1.3.1
|
||||||
sqlparse==0.4.3
|
sqlparse==0.4.3
|
||||||
urllib3==1.26.13
|
urllib3==1.26.13
|
||||||
yarg==0.1.9
|
uvicorn==0.27.1
|
||||||
|
uvloop==0.19.0
|
||||||
|
watchfiles==0.21.0
|
||||||
|
websockets==12.0
|
||||||
|
yarg==0.1.9
|
||||||
46
one_trip_api/requirements.txt.old
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
asgiref==3.5.2
|
||||||
|
async-timeout==4.0.2
|
||||||
|
attrs==22.1.0
|
||||||
|
autobahn==22.7.1
|
||||||
|
Automat==22.10.0
|
||||||
|
certifi==2022.9.24
|
||||||
|
cffi==1.15.1
|
||||||
|
channels==4.0.0
|
||||||
|
channels-redis==4.0.0
|
||||||
|
charset-normalizer==2.1.1
|
||||||
|
constantly==15.1.0
|
||||||
|
cryptography==38.0.4
|
||||||
|
daphne==4.0.0
|
||||||
|
Django==4.1.3
|
||||||
|
django-cors-headers==3.13.0
|
||||||
|
django-filter==22.1
|
||||||
|
django-nested-admin==4.0.2
|
||||||
|
djangorestframework==3.14.0
|
||||||
|
docopt==0.6.2
|
||||||
|
gunicorn==20.1.0
|
||||||
|
hyperlink==21.0.0
|
||||||
|
idna==3.4
|
||||||
|
incremental==22.10.0
|
||||||
|
Markdown==3.4.1
|
||||||
|
msgpack==1.0.4
|
||||||
|
packaging==21.3
|
||||||
|
Pillow==9.3.0
|
||||||
|
pipreqs==0.4.11
|
||||||
|
pyasn1==0.4.8
|
||||||
|
pyasn1-modules==0.2.8
|
||||||
|
pycparser==2.21
|
||||||
|
pyOpenSSL==22.1.0
|
||||||
|
pyparsing==3.0.9
|
||||||
|
python-monkey-business==1.0.0
|
||||||
|
pytz==2022.6
|
||||||
|
redis==4.3.5
|
||||||
|
requests==2.28.1
|
||||||
|
service-identity==21.1.0
|
||||||
|
six==1.16.0
|
||||||
|
sqlparse==0.4.3
|
||||||
|
Twisted==22.10.0
|
||||||
|
txaio==22.2.1
|
||||||
|
typing_extensions==4.4.0
|
||||||
|
urllib3==1.26.13
|
||||||
|
yarg==0.1.9
|
||||||
|
zope.interface==5.5.2
|
||||||
15
one_trip_api/users/middleware.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# https://stackoverflow.com/a/41728627/13538080
|
||||||
|
|
||||||
|
from django.http import request
|
||||||
|
|
||||||
|
class ExemptCSRFMiddleware:
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
|
||||||
|
if request.path_info in ["/auth/token", "/auth/users/"]:
|
||||||
|
setattr(request, '_dont_enforce_csrf_checks', True)
|
||||||
|
|
||||||
|
response = self.get_response(request)
|
||||||
|
return response
|
||||||
@@ -21,6 +21,8 @@ class UserSerializer(serializers.ModelSerializer): # https://stackoverflow.com/
|
|||||||
|
|
||||||
return super().update(instance, validated_data)
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
image = serializers.ImageField(required=False, max_length=None, use_url=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
fields = ("id", "username", "first_name", "last_name", "password", "image", "homegroup", "homegroup_invites")
|
fields = ("id", "username", "first_name", "last_name", "password", "image", "homegroup", "homegroup_invites")
|
||||||
|
|||||||
0
one_trip_api/ws/__init__.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
@@ -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
@@ -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
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
|
class ChatConsumer(AsyncJsonWebsocketConsumer):
|
||||||
|
async def connect(self):
|
||||||
|
query_params = parse_qs(self.scope["query_string"].decode())
|
||||||
|
query_params.setdefault("authorization", [""])
|
||||||
|
|
||||||
|
token_homegroup = await self.get_homegroup_by_token(query_params["authorization"][0])
|
||||||
|
if token_homegroup is None:
|
||||||
|
await self.accept()
|
||||||
|
await self.close(3000)
|
||||||
|
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):
|
||||||
|
if (close_code != 3000):
|
||||||
|
await self.channel_layer.group_discard(self.room_group_name, self.channel_name)
|
||||||
|
|
||||||
|
async def broadcast_update(self, event):
|
||||||
|
await self.send_json(content={"type": "recommend_update", "hash": event["hash"]})
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def get_homegroup_by_token(self, tokenString):
|
||||||
|
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
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
@@ -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
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
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.
|
||||||
BIN
planning/backend-structure.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
58
readme.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# One Trip
|
||||||
|
|
||||||
|
## Technologies Used
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- Django
|
||||||
|
- Django Rest Framework (REST API)
|
||||||
|
- Django Channels (WebSocket interface)
|
||||||
|
- SQLite (Only because I am not deploying this to the Play Store)
|
||||||
|
- Redis (WebSocket cache)
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
- Daphne (ASGI Application server)
|
||||||
|
- Nginx
|
||||||
|
- Ubuntu
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- Flutter
|
||||||
|
|
||||||
|
## Pictures
|
||||||
|
|
||||||
|
### Main Menu
|
||||||
|

|
||||||
|
|
||||||
|
### Profile Page
|
||||||
|

|
||||||
|
|
||||||
|
### Search for Users
|
||||||
|

|
||||||
|
|
||||||
|
Searched Users are sorted by first name, then last name, then username
|
||||||
|
|
||||||
|
### Create, Edit, and Delete Recipes
|
||||||
|

|
||||||
|
|
||||||
|
Recipes and individual ingredients can be deleted by swiping them off of the screen
|
||||||
|
|
||||||
|
### Search for Recipes to add to Shopping List
|
||||||
|

|
||||||
|
|
||||||
|
### Delete Items from List
|
||||||
|
https://user-images.githubusercontent.com/44736322/206134495-b6b97f81-b77b-4068-8359-39e64d5312ca.mp4
|
||||||
|
|
||||||
|
### Add Additional Items
|
||||||
|

|
||||||
|
|
||||||
|
### Shopping List Page
|
||||||
|

|
||||||
|
|
||||||
|
### Backend Data Models
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||