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/profile
/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"
package="com.example.one_trip">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="One Trip"
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 = "http://192.168.0.16:8000";
const String baseURL = "https://groceries.alaevens.ca";
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;

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:http/http.dart' as http;
class RecipeIngredient {
class ListIngredient {
int id;
String name;
String? quantity;
int list;
bool inCart;
RecipeIngredient({
ListIngredient({
required this.id,
required this.name,
required this.quantity,
required this.list,
required this.inCart,
});
factory RecipeIngredient.fromJson(Map<String, dynamic> json) {
return RecipeIngredient(
factory ListIngredient.fromJson(Map<String, dynamic> json) {
return ListIngredient(
id: json["id"] as int,
name: json["name"] as String,
quantity: json["quantity"] as String?,
list: json["list"] as int,
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/";
String token = TokenSingleton().getToken();
Map<String, dynamic> body = {
"name": name,
"list": list,
};
if (quantity != null) {
body["quantity"] = quantity;
}
http.Response response = await http.post(
Uri.parse(requestURL),
headers: {"Authorization": "Token $token"},
body: {
"name": name,
"recipe": "$recipeID",
headers: {
"Authorization": "Token $token",
"Content-Type": "application/json",
},
body: jsonEncode(body),
);
if (response.statusCode == 201) {
return RecipeIngredient.fromJson(jsonDecode(response.body));
return ListIngredient.fromJson(jsonDecode(response.body));
} else {
return null;
}
}
Future<RecipeIngredient?> patch(String name) async {
Future<ListIngredient?> patch(
{String? name, String? quantity, bool? inCart}) async {
String requestURL = "$baseURL/api/listingredients/$id/";
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),
headers: {"Authorization": "Token $token"}, body: {"name": name});
headers: {
"Authorization": "Token $token",
"Content-Type": "application/json",
},
body: jsonEncode(body));
if (response.statusCode == 200) {
return RecipeIngredient.fromJson(jsonDecode(response.body));
return ListIngredient.fromJson(jsonDecode(response.body));
}
return null;
@@ -71,4 +100,15 @@ class RecipeIngredient {
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/consts.dart';
import 'package:one_trip/api/models/homegroup.dart';
import 'package:one_trip/api/models/recipeingredient.dart';
import 'package:http/http.dart' as http;
import 'package:one_trip/api/searchresult.dart';
class Recipe {
int id;
@@ -46,26 +46,51 @@ class Recipe {
return null;
}
static Future<List<Recipe>> getList(int groupID) async {
Homegroup? group = await Homegroup.get(groupID);
if (group == null) {
return [];
}
static Future<List<Recipe>> getList() async {
const String requestURL = "$baseURL/api/recipes/";
String token = TokenSingleton().getToken();
http.Response response = await http.get(
Uri.parse(requestURL),
headers: {"Authorization": "Token $token"},
);
List<Recipe> recipes = [];
for (int recipeID in group.recipes) {
Recipe? recipe = await Recipe.get(recipeID);
if (recipe != null) {
// TODO: implement sorted insert
recipes.add(recipe);
if (response.statusCode == 200) {
var body = jsonDecode(response.body);
for (var recipe in body) {
recipes.add(Recipe.fromJson(recipe));
}
}
recipes.sort(((a, b) => a.name.compareTo(b.name)));
return recipes;
}
static Future<SearchResult<Recipe>> search(String query, int page) async {
String requestURL = "$baseURL/api/searchrecipes/?page=$page&search=$query";
requestURL = requestURL.replaceAll(RegExp(r"\s+"), "+");
String token = TokenSingleton().getToken();
final http.Response response = await http.get(
Uri.parse(requestURL),
headers: {"Authorization": "Token $token"},
);
if (response.statusCode == 200) {
Map<String, dynamic> json = jsonDecode(response.body);
List<Recipe> recipes = [];
for (var recipeObject in json["results"]) {
Recipe r = Recipe.fromJson(recipeObject);
recipes.add(r);
}
return SearchResult<Recipe>(
results: recipes, next: json["next"] as String?);
}
return SearchResult<Recipe>(results: [], next: null);
}
static Future<Recipe?> create(String name, int group) async {
String requestURL = "$baseURL/api/recipes/";
String token = TokenSingleton().getToken();

View File

@@ -7,11 +7,13 @@ import 'package:http/http.dart' as http;
class RecipeIngredient {
int id;
String name;
String? quantity;
int recipe;
RecipeIngredient({
required this.id,
required this.name,
required this.quantity,
required this.recipe,
});
@@ -19,20 +21,32 @@ class RecipeIngredient {
return RecipeIngredient(
id: json["id"] as int,
name: json["name"] as String,
quantity: json["quantity"] as String?,
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/";
String token = TokenSingleton().getToken();
Map<String, dynamic> body = {
"name": name,
"recipe": recipeID,
};
if (quantity != null) {
body["quantity"] = quantity;
}
http.Response response = await http.post(
Uri.parse(requestURL),
headers: {"Authorization": "Token $token"},
body: {
"name": name,
"recipe": "$recipeID",
headers: {
"Authorization": "Token $token",
"Content-Type": "application/json",
},
body: jsonEncode(body),
);
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 token = TokenSingleton().getToken();
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) {
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/consts.dart';
import 'package:http/http.dart' as http;
import 'package:one_trip/api/searchresult.dart';
class SearchResult {
List<SimpleUser> users;
String? next;
// class SearchResult {
// List<SimpleUser> users;
// String? next;
SearchResult({required this.users, required this.next});
}
// SearchResult({required this.users, required this.next});
// }
class SimpleUser {
int id;
@@ -28,17 +29,21 @@ class SimpleUser {
});
factory SimpleUser.fromJson(Map<String, dynamic> json) {
String? imagePath = json["image"] as String?;
String? imageUrl = imagePath != null ? "$baseURL/media/$imagePath" : null;
return SimpleUser(
id: json["id"] as int,
username: json["username"] as String,
firstName: json["first_name"] as String,
lastName: json["last_name"] as String,
imageUrl: json["image"] as String?,
imageUrl: imageUrl,
);
}
static Future<SimpleUser?> get({int? id}) async {
String requestURL = "$baseURL/auth/users/${id ?? 'me'}";
String requestURL =
id == null ? "$baseURL/auth/users/me" : "$baseURL/auth/users/$id/";
String token = TokenSingleton().getToken();
final http.Response response = await http.get(
@@ -54,7 +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 = "";
// if (url != null) {
// requestURL = url;
@@ -81,9 +86,10 @@ class SimpleUser {
users.add(u);
}
return SearchResult(users: users, next: json["next"] as String?);
return SearchResult<SimpleUser>(
results: users, next: json["next"] as String?);
}
return SearchResult(users: [], next: null);
return SearchResult<SimpleUser>(results: [], next: null);
}
}

View File

@@ -28,13 +28,16 @@ class User {
List<dynamic> invitesDynamic = json["homegroup_invites"];
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(
id: json["id"] as int,
username: json["username"] as String,
firstName: json["first_name"] as String,
lastName: json["last_name"] as String,
homegroup: json["homegroup"] as int?,
imageUrl: json["image"] as String?,
imageUrl: imageUrl,
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
Widget build(BuildContext context) {
return MaterialApp(
title: 'Grocery Helper',
title: 'One Trip',
theme: lightTheme,
darkTheme: darkTheme,
themeMode: ThemeMode.system,

View File

@@ -1,52 +1,313 @@
// import 'package:flutter/material.dart';
// import 'package:one_trip/api/models/recipe.dart';
// import 'package:one_trip/api/models/user.dart';
// import 'package:one_trip/pages/recipes_page/widgets/recipe_card_widget.dart';
// import 'package:one_trip/widgets/text_entry_dialog.dart';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:one_trip/api/auth.dart';
import 'package:one_trip/api/consts.dart';
import 'package:one_trip/api/models/list.dart';
import 'package:one_trip/api/models/listingredient.dart';
import 'package:one_trip/api/models/user.dart';
import 'package:one_trip/pages/list_page/widgets/listrow.dart';
import 'package:one_trip/pages/list_page/widgets/search_recipes_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 {
// const RecipesPage({super.key});
class ListPage extends StatefulWidget {
const ListPage({super.key});
// @override
// State<RecipesPage> createState() => _RecipesPageState();
// }
@override
State<ListPage> createState() => _ListPageState();
}
// class _RecipesPageState extends State<RecipesPage> {
// late Future<List<Recipe>> _recipes;
// late User _userInfo;
class _ListPageState extends State<ListPage> {
ShoppingList? _list;
late Future<bool> _isLoaded;
User? _userInfo;
WebSocketChannel? _wsChannel;
// Future<List<Recipe>> _fetchList() async {
// User? userInfo = await User.getMe();
// if (userInfo == null || userInfo.homegroup == null) {
// return [];
// }
// _userInfo = userInfo;
Future<bool> _fetchList() async {
User? userInfo = await User.getMe();
_userInfo = userInfo;
// List<Recipe> recipes = await Recipe.getList(_userInfo.homegroup!);
// return recipes;
// }
if (userInfo == null || userInfo.homegroup == null) {
return false;
}
// @override
// void initState() {
// super.initState();
// _recipes = _fetchList();
// }
_list = await ShoppingList.get(userInfo.homegroup!);
_connectSocket();
return true;
}
// @override
// Widget build(BuildContext context) {
// return FutureBuilder(
// future: _recipes,
// builder: (context, snapshot) {
// if (snapshot.hasError) {
// return Text(snapshot.error.toString());
// } else if (snapshot.hasData &&
// snapshot.connectionState == ConnectionState.done) {
// return RecipeList(
// recipes: snapshot.data!, homegroup: _userInfo.homegroup!);
// } else {
// return const Center(child: CircularProgressIndicator());
// }
// },
// );
// }
// }
void _connectSocket() async {
String token = TokenSingleton().getToken();
_wsChannel = WebSocketChannel.connect(
Uri.parse("$baseWsURL/ws/?authorization=$token"));
_wsChannel!.stream.listen(
(event) async {
Map<String, dynamic> json = jsonDecode(event);
if (json.keys.contains("type") && json["type"] == "recommend_update") {
if (json["hash"] != _list.hashCode) {
ShoppingList? newList = await ShoppingList.get(_list!.homegroup);
if (newList != null) {
setState(() {
_list = newList;
});
}
}
}
},
// 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:one_trip/api/models/simpleuser.dart';
import 'package:one_trip/api/searchresult.dart';
import 'package:one_trip/pages/profile_page/widgets/user_chip.dart';
import 'package:one_trip/theme.dart';
import 'package:one_trip/widgets/pagination_listview.dart';
@@ -118,9 +119,9 @@ class _InviteHomegroupDialogState extends State<InviteHomegroupDialog> {
return const Divider();
},
dataProvider: (int page) async {
SearchResult result =
SearchResult<SimpleUser> result =
await SimpleUser.search(_searchController.text, page);
List<dynamic> users = List<dynamic>.from(result.users);
List<dynamic> users = List<dynamic>.from(result.results);
if (result.next == null) {
users.add(null);
@@ -138,12 +139,15 @@ class _InviteHomegroupDialogState extends State<InviteHomegroupDialog> {
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
style: negativeButtonStyle(context),
onPressed: () => Navigator.pop(context),
child: const Text("Cancel"),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, selectedIDs),
child: const Text("Done")),
style: positiveButtonStyle(context),
onPressed: () => Navigator.pop(context, selectedIDs),
child: const Text("Done"),
),
],
),
)

View File

@@ -23,7 +23,7 @@ class _RecipesPageState extends State<RecipesPage> {
return [];
}
List<Recipe> recipes = await Recipe.getList(userInfo.homegroup!);
List<Recipe> recipes = await Recipe.getList();
return recipes;
}
@@ -106,47 +106,44 @@ class _RecipeListState extends State<RecipeList> {
Widget build(BuildContext context) {
return Stack(
children: [
ListView.separated(
Scrollbar(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(
8, 8, 8, kFloatingActionButtonMargin + 48),
itemCount: _recipes.length,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) => RecipeCard(
recipe: _recipes[index],
isExpanded: _expandedCard == index,
onTap: () {
setState(() {
if (_expandedCard == index) {
_expandedCard = null;
} else {
_expandedCard = index;
}
});
},
onDismiss: () async {
if (_expandedCard != null && _expandedCard! > index) {
_expandedCard = _expandedCard! - 1;
}
bool success = await _recipes[index].delete();
if (!success) {
showError("Permanent deletion of recipe failed.");
}
setState(() {
_recipes.removeAt(index);
});
},
onChanged: () async {
Recipe? newRecipe = await Recipe.get(_recipes[index].id);
if (newRecipe != null) {
child: ListView.separated(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(
8, 8, 8, kFloatingActionButtonMargin + 48),
itemCount: _recipes.length,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) => RecipeCard(
recipe: _recipes[index],
isExpanded: _expandedCard == index,
onTap: () {
setState(() {
_recipes[index] = newRecipe;
if (_expandedCard == index) {
_expandedCard = null;
} else {
_expandedCard = index;
}
});
}
},
},
onDismiss: () {
if (_expandedCard != null && _expandedCard! > index) {
_expandedCard = _expandedCard! - 1;
}
setState(() {
_recipes.removeAt(index);
});
},
onChanged: () async {
Recipe? newRecipe = await Recipe.get(_recipes[index].id);
if (newRecipe != null) {
setState(() {
_recipes[index] = newRecipe;
});
}
},
),
),
),
Align(
@@ -154,6 +151,7 @@ class _RecipeListState extends State<RecipeList> {
child: Padding(
padding: const EdgeInsets.all(8.0),
child: FloatingActionButton.extended(
heroTag: "add-ingredient",
onPressed: () async {
String? name =
await textEntryDialog(context, "Recipe Name", "Recipe");
@@ -180,11 +178,11 @@ class _RecipeListState extends State<RecipeList> {
}
},
label: Row(
children: const [Icon(Icons.note_add), Text("New Recipe")],
children: const [Icon(Icons.post_add), Text("Recipe")],
),
),
),
)
),
],
);
}

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/recipe.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 {
final Recipe recipe;
@@ -85,6 +85,7 @@ class _RecipeCardState extends State<RecipeCard> with TickerProviderStateMixin {
: DismissDirection.endToStart,
key: Key("${widget.recipe.id}"),
onDismissed: (direction) => widget.onDismiss(),
confirmDismiss: (direction) => widget.recipe.delete(),
onUpdate: (details) {
setState(() {
dismissAmount = details.progress;
@@ -92,16 +93,8 @@ class _RecipeCardState extends State<RecipeCard> with TickerProviderStateMixin {
});
},
background: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Color.fromARGB(255, 255, 0, 0),
Color.fromARGB(255, 255, 170, 170),
],
),
),
color: Color.lerp(Colors.transparent, Colors.red,
min(dismissAmount * 2.5, 1)),
child: Align(
alignment: Alignment.centerRight,
child: SizedBox(
@@ -109,7 +102,7 @@ class _RecipeCardState extends State<RecipeCard> with TickerProviderStateMixin {
child: Icon(
Icons.delete,
size: min(27.5 * dismissAmount + 20, 35),
color: willDismiss ? Colors.red : Colors.white,
color: Colors.white,
),
),
),
@@ -152,15 +145,18 @@ class _RecipeCardState extends State<RecipeCard> with TickerProviderStateMixin {
shape: const MaterialStatePropertyAll(
RoundedRectangleBorder())),
onPressed: () async {
String? name = await textEntryDialog(
context, "Ingredient Name", "Ingredient");
IngredientDetails? details =
await ingredientDialog(context, "", "");
if (name == null || name == "") {
if (details == null || details.name == "") {
return;
}
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) {
widget.onChanged();
}
@@ -193,9 +189,11 @@ class _IngredientSectionState extends State<IngredientSection> {
@override
Widget build(BuildContext context) {
return Container(
return Material(
elevation: 10,
color: Theme.of(context).colorScheme.surface,
child: ListView.builder(
physics: const NeverScrollableScrollPhysics(),
padding: widget.ingredients.isEmpty
? EdgeInsets.zero
: const EdgeInsets.all(8),
@@ -208,16 +206,8 @@ class _IngredientSectionState extends State<IngredientSection> {
key: Key("${widget.ingredients[index].id}"),
direction: DismissDirection.endToStart,
background: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Color.fromARGB(255, 255, 0, 0),
Color.fromARGB(255, 255, 170, 170),
],
),
),
color: Color.lerp(Colors.transparent, Colors.red,
min(dismissAmount * 2.5, 1)),
child: Align(
alignment: Alignment.centerRight,
child: SizedBox(
@@ -225,7 +215,7 @@ class _IngredientSectionState extends State<IngredientSection> {
child: Icon(
Icons.delete,
size: min(27.5 * dismissAmount + 20, 35),
color: willDismiss ? Colors.red : Colors.white,
color: Colors.white,
),
),
),
@@ -236,42 +226,51 @@ class _IngredientSectionState extends State<IngredientSection> {
willDismiss = details.reached;
});
},
confirmDismiss: (direction) async =>
await widget.ingredients[index].delete(),
onDismissed: (direction) async {
bool success = await widget.ingredients[index].delete();
if (success) {
widget.onChanged();
}
widget.onChanged();
},
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
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,
)),
IconButton(
onPressed: () async {
String? name = await textEntryDialog(
context, "Change Ingredient Name", "Ingredient",
defaultValue: widget.ingredients[index].name);
onPressed: () async {
IngredientDetails? details = await ingredientDialog(
context,
widget.ingredients[index].name,
widget.ingredients[index].quantity ?? "");
if (name == null || name == "") {
return;
}
if (details == null || details.name == "") {
return;
}
RecipeIngredient? changed =
await widget.ingredients[index].patch(name);
if (changed != null) {
widget.onChanged();
}
},
icon: const Icon(Icons.edit)),
RecipeIngredient? changed =
await widget.ingredients[index].patch(
name: details.name,
quantity: details.quantity != ""
? details.quantity
: null);
if (changed != null) {
widget.onChanged();
}
},
icon: const Icon(Icons.edit),
),
],
),
),
Divider(
height: 1,
height: 2,
thickness: 1,
color: index % 2 == 0
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.error)

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:one_trip/pages/list_page/list_page.dart';
import 'package:one_trip/pages/profile_page/profile_page.dart';
import 'package:one_trip/pages/recipes_page/recipes_page.dart';
import 'package:one_trip/pages/themetest.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@@ -17,16 +17,16 @@ class _HomeScreenState extends State<HomeScreen> {
"Shopping List",
"Saved Recipes",
"Your Profile",
"Color Debug"
// "Color Debug"
];
@override
void initState() {
_pages = <Widget>[
Container(),
const ListPage(),
const RecipesPage(),
const ProfilePage(),
const ColorPage()
// const ColorPage()
];
super.initState();
}
@@ -57,11 +57,11 @@ class _HomeScreenState extends State<HomeScreen> {
label: "Profile",
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
),
BottomNavigationBarItem(
icon: const Icon(Icons.grid_3x3),
label: "Colors",
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
)
// BottomNavigationBarItem(
// icon: const Icon(Icons.grid_3x3),
// label: "Colors",
// backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
// )
],
currentIndex: _selectedPage,
onTap: (value) {

View File

@@ -12,10 +12,6 @@ final _lightScheme =
final darkTheme = ThemeData(
colorScheme: _darkScheme,
toggleableActiveColor: _darkScheme.primary,
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: _darkScheme.primary,
splashColor: _darkScheme.secondary,
),
cardColor: _darkScheme.secondaryContainer);
final lightTheme = ThemeData(
@@ -31,6 +27,7 @@ final bottomButtonStyle = ButtonStyle(
BorderRadius.vertical(top: Radius.zero, bottom: Radius.circular(10)),
),
),
elevation: const MaterialStatePropertyAll(10),
);
// https://stackoverflow.com/a/51119796/13538080
@@ -41,3 +38,28 @@ class MyBehavior extends ScrollBehavior {
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) seperatorBuilder;
final bool? shrinkWrap;
final bool? prefetchOne;
final ListViewState state;
final EdgeInsetsGeometry? padding;
final Future<List<dynamic>> Function(int page) dataProvider;
const PaginationListView(
{super.key,
required this.itemBuilder,
required this.dataProvider,
required this.state,
required this.seperatorBuilder,
this.shrinkWrap});
const PaginationListView({
super.key,
required this.itemBuilder,
required this.dataProvider,
required this.state,
required this.seperatorBuilder,
this.prefetchOne,
this.shrinkWrap,
this.padding,
});
@override
State<PaginationListView> createState() => _PaginationListViewState();
@@ -66,6 +71,15 @@ class _PaginationListViewState extends State<PaginationListView> {
super.initState();
_scrollController = ScrollController();
_state = widget.state;
if (widget.prefetchOne ?? false) {
_state = ListViewState.inUse;
_data = [];
_dataLeft = true;
_isLoading = false;
_pagesLoaded = 0;
consumeData();
}
}
@override
@@ -98,6 +112,7 @@ class _PaginationListViewState extends State<PaginationListView> {
controller: _scrollController,
itemCount: _data.length,
shrinkWrap: widget.shrinkWrap ?? false,
padding: widget.padding,
itemBuilder: (context, index) =>
widget.itemBuilder(context, _data[index]),
separatorBuilder: (context, index) =>

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:one_trip/theme.dart';
class TextEntryForm extends StatefulWidget {
final String title;
@@ -55,13 +56,16 @@ class _TextEntryFormState extends State<TextEntryForm> {
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
style: negativeButtonStyle(context),
onPressed: () => Navigator.pop(context),
child: const Text("Cancel"),
),
ElevatedButton(
onPressed: () =>
Navigator.pop(context, _textController.text),
child: const Text("Done")),
style: positiveButtonStyle(context),
onPressed: () =>
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) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar));
gtk_header_bar_set_title(header_bar, "one_trip");
gtk_header_bar_set_title(header_bar, "One Trip");
gtk_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else {
gtk_window_set_title(window, "one_trip");
gtk_window_set_title(window, "One Trip");
}
gtk_window_set_default_size(window, 1280, 720);

View File

@@ -392,6 +392,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
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:
dependency: transitive
description:

View File

@@ -38,6 +38,7 @@ dependencies:
flutter_svg_provider: ^1.0.3
image_picker: ^0.8.6
flutter_launcher_icons: ^0.11.0
web_socket_channel: ^2.2.0
# 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
db.sqlite3
db.sqlite3-journal
media
dev_data/
# 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.

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.db.models.signals import post_save
class ApiConfig(AppConfig):
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):
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")
class ListIngredient(models.Model):
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")
in_cart = models.BooleanField(default=False)

View File

@@ -1,16 +1,20 @@
from rest_framework import serializers
from api.models import *
from users.serializers import UserSerializer
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
channel_layer = get_channel_layer()
class RecipeIngredientSerializer(serializers.ModelSerializer):
class Meta:
model = RecipeIngredient
fields = ["id", "name", "recipe"]
fields = ["id", "name", "quantity", "recipe"]
class ListIngredientSerializer(serializers.ModelSerializer):
class Meta:
model = ListIngredient
fields = ["id", "name", "list", "in_cart"]
fields = ["id", "name", "quantity", "list", "in_cart"]
class RecipeSerializer(serializers.ModelSerializer):
ingredients = serializers.SerializerMethodField()

View File

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

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.request import Request
from api.serializers import *
from api.models import *
class RecipeView(viewsets.ModelViewSet):
class HasHomegroup(permissions.BasePermission):
def has_permission(self, request: Request, view):
if not request.user.homegroup:
return False
return super().has_permission(request, view)
class Pagination(pagination.PageNumberPagination):
page_size = 10
class NoListModelViewset(mixins.CreateModelMixin, mixins.DestroyModelMixin, mixins.UpdateModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
pass
class RecipeSearchView(viewsets.ModelViewSet):
serializer_class = RecipeSerializer
permission_classes = [permissions.IsAuthenticated, HasHomegroup]
queryset = Recipe.objects.all()
filter_backends = [filters.SearchFilter]
search_fields = ["name"]
pagination_class = Pagination
def list(self, request: Request, *args, **kwargs):
queryset = self.filter_queryset(Recipe.objects.filter(homegroup=request.user.homegroup).order_by("name"));
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.serializer_class(queryset, many=True)
return Response(serializer.data)
class RecipeAllView(viewsets.ModelViewSet):
serializer_class = RecipeSerializer
permission_classes = [permissions.IsAuthenticated, HasHomegroup]
queryset = Recipe.objects.all()
filter_backends = [filters.SearchFilter]
search_fields = ["name"]
def list(self, request: Request, *args, **kwargs):
queryset = self.filter_queryset(Recipe.objects.filter(homegroup=request.user.homegroup).order_by("name"));
serializer = self.serializer_class(queryset, many=True)
return Response(serializer.data)
class HomegroupView(viewsets.ModelViewSet):
serializer_class = HomegroupSerializer
queryset = Homegroup.objects.all()
class HomegroupInviteView(viewsets.ModelViewSet):
class HomegroupInviteView(NoListModelViewset):
serializer_class = InviteSerializer
queryset = HomegroupInvite.objects.all()
class RecipeIngredientView(viewsets.ModelViewSet):
class RecipeIngredientView(NoListModelViewset):
serializer_class = RecipeIngredientSerializer
queryset = RecipeIngredient.objects.all()
class ListIngredientView(viewsets.ModelViewSet):
class ListIngredientView(NoListModelViewset):
serializer_class = ListIngredientSerializer
queryset = ListIngredient.objects.all()
class ListView(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
class ListView(mixins.RetrieveModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet):
serializer_class = ListSerializer
queryset = List.objects.all()

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
from django.core.asgi import get_asgi_application
import django
settings = 'one_trip_api.settings.dev'
if os.getenv("DJANGO_RELEASE", False):
settings = 'one_trip_api.settings.release'
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'.
BASE_DIR = Path(__file__).resolve().parent.parent.parent
STATIC_URL = "static/"
STATIC_URL = "/static/"
MEDIA_URL = "/media/"
@@ -33,6 +33,7 @@ REST_FRAMEWORK = {
INSTALLED_APPS = [
'api',
'users',
'ws',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
@@ -46,6 +47,7 @@ INSTALLED_APPS = [
]
MIDDLEWARE = [
'users.middleware.ExemptCSRFMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware',
@@ -75,6 +77,15 @@ TEMPLATES = [
]
WSGI_APPLICATION = 'one_trip_api.wsgi.application'
ASGI_APPLICATION = 'one_trip_api.asgi.application'
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [("redis", 6379)],
},
},
}
DATABASES = {
'default': {

View File

@@ -4,6 +4,8 @@ DEBUG = True
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 = ["*"]
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'
DATA_ROOT = Path("/opt/django/data").resolve()
DATA_ROOT = Path("/home/groceries/data/").resolve()
MEDIA_ROOT = DATA_ROOT.joinpath("media/")
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():
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.conf.urls.static import static
from django.conf import settings
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include("api.urls")),
path('auth/', include("users.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)
print("WSGI Started")
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
channels==4.0.0
channels-redis==4.0.0
charset-normalizer==2.1.1
click==8.1.7
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
idna==3.4
gunicorn==21.2.0
h11==0.14.0
httptools==0.6.1
idna==3.6
Markdown==3.4.1
msgpack==1.0.4
packaging==21.3
Pillow==9.3.0
pipreqs==0.4.11
pyparsing==3.0.9
python-dotenv==1.0.1
python-monkey-business==1.0.0
pytz==2022.6
PyYAML==6.0.1
redis==4.3.5
requests==2.28.1
six==1.16.0
sniffio==1.3.1
sqlparse==0.4.3
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

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)
image = serializers.ImageField(required=False, max_length=None, use_url=False)
class Meta:
model = User
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)