Compare commits

..

10 Commits

Author SHA1 Message Date
Alexander Laevens
b18f43a48b Tweaks for old server. Ready for re-deployment 2024-03-03 20:35:39 -07:00
Alexander Laevens
caf98b3e84 Partial working docker image 2024-03-03 13:29:58 -07:00
Alexander Laevens
4d0388b262 +Add quantity field to ingredients
+Clear list now requires confirmation
+Confirm / Cancel buttons are now coloured
2022-12-07 02:13:34 -07:00
Alexander Laevens
31c4505e49 Add readme
Remove development color swatches page
2022-12-01 01:34:31 -07:00
Alexander Laevens
7fa947b1d7 Add Android Network Permission 2022-11-30 22:29:14 -07:00
Alexander Laevens
df195634c0 Make image field not required 2022-11-30 16:49:02 -07:00
Alexander Laevens
7dd7abc09c Convert dart:io websocket to web_socket_channel
Now send token as query parameter instead
2022-11-30 16:02:42 -07:00
Alexander Laevens
2e7a306279 fix daphne deployment 2022-11-30 14:47:23 -07:00
Alexander Laevens
339b0c6ad9 add websock functionality 2022-11-30 02:52:56 -07:00
Alexander Laevens
34edcd53cb Try CSRF Exempt auth/token 2022-11-27 21:17:44 -07:00
72 changed files with 1724 additions and 260 deletions

4
one_trip/.gitignore vendored
View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -1,4 +1,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;

View 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);
}

View File

@@ -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);
} }

View File

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

View File

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

View File

@@ -3,13 +3,14 @@ import 'dart:convert';
import 'package:one_trip/api/auth.dart'; import 'package:one_trip/api/auth.dart';
import 'package:one_trip/api/consts.dart'; import 'package:one_trip/api/consts.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:one_trip/api/searchresult.dart';
class SearchResult { // class SearchResult {
List<SimpleUser> users; // List<SimpleUser> users;
String? next; // String? next;
SearchResult({required this.users, required this.next}); // SearchResult({required this.users, required this.next});
} // }
class SimpleUser { class SimpleUser {
int id; int id;
@@ -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);
} }
} }

View File

@@ -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,
); );
} }

View File

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

View File

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

View File

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

View 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)),
],
),
),
);
}
}

View File

@@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import 'package:one_trip/api/models/recipe.dart';
import 'package:one_trip/api/searchresult.dart';
import 'package:one_trip/theme.dart';
import 'package:one_trip/widgets/pagination_listview.dart';
class SearchRecipesDialog extends StatefulWidget {
const SearchRecipesDialog({super.key});
@override
State<SearchRecipesDialog> createState() => _SearchRecipesDialogState();
}
class _SearchRecipesDialogState extends State<SearchRecipesDialog> {
final TextEditingController _searchController = TextEditingController();
ListViewState _listState = ListViewState.inactive;
List<int> selectedIDs = [];
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Search your Recipes",
style: Theme.of(context).textTheme.titleMedium,
),
const Divider(),
TextFormField(
controller: _searchController,
textInputAction: TextInputAction.search,
onFieldSubmitted: (value) {
setState(() {
_listState = ListViewState.changed;
});
},
onChanged: (value) {
setState(() {
_listState = ListViewState.inactive;
});
},
decoration: InputDecoration(
label: const Text("Search"),
isDense: true,
suffix: IconButton(
onPressed: () {
setState(() {
_listState = ListViewState.changed;
});
// https://flutterigniter.com/dismiss-keyboard-form-lose-focus/
FocusScopeNode currentFocus = FocusScope.of(context);
if (!currentFocus.hasPrimaryFocus) {
currentFocus.unfocus();
}
},
icon: const Icon(Icons.search),
),
),
),
const SizedBox(height: 8),
LayoutBuilder(
builder: (builder, constraints) {
return ConstrainedBox(
constraints: BoxConstraints.expand(
width: constraints.maxWidth - 8,
height: 160,
),
child: PaginationListView(
state: _listState,
shrinkWrap: true,
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;
}

View File

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

View File

@@ -23,7 +23,7 @@ class _RecipesPageState extends State<RecipesPage> {
return []; return [];
} }
List<Recipe> recipes = await Recipe.getList(userInfo.homegroup!); List<Recipe> recipes = await Recipe.getList();
return recipes; return recipes;
} }
@@ -106,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")],
), ),
), ),
), ),
) ),
], ],
); );
} }

View File

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

View File

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

View File

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

View 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;
}

View 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;
}

View File

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

View File

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

View File

@@ -40,11 +40,11 @@ static void my_application_activate(GApplication* application) {
if (use_header_bar) { if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar)); gtk_widget_show(GTK_WIDGET(header_bar));
gtk_header_bar_set_title(header_bar, "one_trip"); gtk_header_bar_set_title(header_bar, "One Trip");
gtk_header_bar_set_show_close_button(header_bar, TRUE); gtk_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else { } else {
gtk_window_set_title(window, "one_trip"); gtk_window_set_title(window, "One Trip");
} }
gtk_window_set_default_size(window, 1280, 720); gtk_window_set_default_size(window, 1280, 720);

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
**/.git
**/dev_data
**/prod_data
**/venv
**/__pycache__
**/db.sqlite3
**/nginx.conf

View File

@@ -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
View 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" ]

View 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

View File

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

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.1.3 on 2022-11-29 16:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api', '0004_remove_recipe_list_delete_ingredient'),
]
operations = [
migrations.AddField(
model_name='list',
name='updates',
field=models.BigIntegerField(default=0),
),
]

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,8 @@ from rest_framework import routers
from api import views from api import views
router = routers.DefaultRouter() router = routers.DefaultRouter()
router.register(r'recipes', views.RecipeView) router.register(r'recipes', views.RecipeAllView)
router.register(r'searchrecipes', views.RecipeSearchView)
router.register(r'lists', views.ListView) router.register(r'lists', views.ListView)
router.register(r'recipeingredients', views.RecipeIngredientView) router.register(r'recipeingredients', views.RecipeIngredientView)
router.register(r'listingredients', views.ListIngredientView) router.register(r'listingredients', views.ListIngredientView)

View File

@@ -1,28 +1,69 @@
from rest_framework import viewsets, mixins, views, status, permissions from rest_framework import viewsets, mixins, permissions, request, pagination, filters
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.request import Request
from api.serializers import * from api.serializers import *
from api.models import * from api.models import *
class RecipeView(viewsets.ModelViewSet): class HasHomegroup(permissions.BasePermission):
def has_permission(self, request: Request, view):
if not request.user.homegroup:
return False
return super().has_permission(request, view)
class Pagination(pagination.PageNumberPagination):
page_size = 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()

View 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

View 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

View 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

View File

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

View File

@@ -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
View 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;
}

View File

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

View File

@@ -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': {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
uvicorn==0.27.1
uvloop==0.19.0
watchfiles==0.21.0
websockets==12.0
yarg==0.1.9 yarg==0.1.9

View 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

View 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

View File

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

View File

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

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

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

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

View File

@@ -0,0 +1,51 @@
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer
from rest_framework.authtoken.models import Token
from api.models import Homegroup
from users.models import User
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

View File

View File

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

View File

@@ -0,0 +1,7 @@
from django.urls import re_path, path
from ws import consumers
websocket_urlpatterns = [
path('ws/', consumers.ChatConsumer.as_asgi(), name='room')
]

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

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

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

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

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

58
readme.md Normal file
View 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
![Login Screen](https://user-images.githubusercontent.com/44736322/206134503-490b7b5c-9ef1-4a32-af2f-a664b587c4d6.png)
### Profile Page
![Profile Page](https://user-images.githubusercontent.com/44736322/206134506-7c035ea7-8431-4116-b61f-f1bbe0b23713.png)
### Search for Users
![Searching for users](https://user-images.githubusercontent.com/44736322/206134507-c2e5c805-3c0e-4ead-9c48-b047e5fb75dc.png)
Searched Users are sorted by first name, then last name, then username
### Create, Edit, and Delete Recipes
![Create Edit Delete Recipes](https://user-images.githubusercontent.com/44736322/206134508-94332b3e-ea62-47e5-8426-c876e9f7ce24.png)
Recipes and individual ingredients can be deleted by swiping them off of the screen
### Search for Recipes to add to Shopping List
![Search Recipes](https://user-images.githubusercontent.com/44736322/206134511-c0fd19b3-9816-4d87-bfde-54fb110c2fe5.png)
### Delete Items from List
https://user-images.githubusercontent.com/44736322/206134495-b6b97f81-b77b-4068-8359-39e64d5312ca.mp4
### Add Additional Items
![Add Individual](https://user-images.githubusercontent.com/44736322/206134513-c6d7bedd-659f-45de-83ef-7aa4b1a9378f.png)
### Shopping List Page
![Shopping List Page](https://user-images.githubusercontent.com/44736322/206134515-1636e086-80a7-45df-801b-af90c2059d10.png)
### Backend Data Models
![backend-structure](https://user-images.githubusercontent.com/44736322/206135954-63c728ce-3662-4e10-9914-557ce65f04ee.png)