Complete recipes page
@@ -47,7 +47,8 @@ android {
|
||||
applicationId "com.example.one_trip"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration.
|
||||
minSdkVersion flutter.minSdkVersion
|
||||
minSdkVersion 18
|
||||
compileSdkVersion 33
|
||||
targetSdkVersion flutter.targetSdkVersion
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.example.one_trip">
|
||||
<application
|
||||
android:label="one_trip"
|
||||
android:label="One Trip"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 564 B After Width: | Height: | Size: 624 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 981 B |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 6.2 KiB |
@@ -1,3 +1,4 @@
|
||||
// const String baseURL = "https://groceries.alaevens.ca";
|
||||
const String baseURL = "http://192.168.0.16:8000";
|
||||
|
||||
const int resultsPerPage = 4;
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import 'package:one_trip/api/auth.dart';
|
||||
import 'package:one_trip/api/consts.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class Ingredient {
|
||||
int id;
|
||||
String name;
|
||||
bool inStock;
|
||||
int contentType;
|
||||
int objectID;
|
||||
|
||||
Ingredient({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.inStock,
|
||||
required this.contentType,
|
||||
required this.objectID,
|
||||
});
|
||||
|
||||
factory Ingredient.fromJson(Map<String, dynamic> json) {
|
||||
return Ingredient(
|
||||
id: json["id"] as int,
|
||||
name: json["name"] as String,
|
||||
inStock: json["in_stock"] as bool,
|
||||
contentType: json["content_type"] as int,
|
||||
objectID: json["object_id"] as int);
|
||||
}
|
||||
}
|
||||
74
one_trip/lib/api/models/listingredient.dart
Normal file
@@ -0,0 +1,74 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:one_trip/api/auth.dart';
|
||||
import 'package:one_trip/api/consts.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class RecipeIngredient {
|
||||
int id;
|
||||
String name;
|
||||
int list;
|
||||
bool inCart;
|
||||
|
||||
RecipeIngredient({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.list,
|
||||
required this.inCart,
|
||||
});
|
||||
|
||||
factory RecipeIngredient.fromJson(Map<String, dynamic> json) {
|
||||
return RecipeIngredient(
|
||||
id: json["id"] as int,
|
||||
name: json["name"] as String,
|
||||
list: json["list"] as int,
|
||||
inCart: json["in_cart"] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<RecipeIngredient?> create(String name, int recipeID) async {
|
||||
const String requestURL = "$baseURL/api/listingredients/";
|
||||
String token = TokenSingleton().getToken();
|
||||
http.Response response = await http.post(
|
||||
Uri.parse(requestURL),
|
||||
headers: {"Authorization": "Token $token"},
|
||||
body: {
|
||||
"name": name,
|
||||
"recipe": "$recipeID",
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 201) {
|
||||
return RecipeIngredient.fromJson(jsonDecode(response.body));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<RecipeIngredient?> patch(String name) async {
|
||||
String requestURL = "$baseURL/api/listingredients/$id/";
|
||||
String token = TokenSingleton().getToken();
|
||||
|
||||
http.Response response = await http.patch(Uri.parse(requestURL),
|
||||
headers: {"Authorization": "Token $token"}, body: {"name": name});
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return RecipeIngredient.fromJson(jsonDecode(response.body));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<bool> delete() async {
|
||||
String requestURL = "$baseURL/api/listingredients/$id/";
|
||||
String token = TokenSingleton().getToken();
|
||||
http.Response response = await http.delete(Uri.parse(requestURL),
|
||||
headers: {"Authorization": "Token $token"});
|
||||
|
||||
if (response.statusCode == 204) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,14 @@ 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/ingredient.dart';
|
||||
import 'package:one_trip/api/models/recipeingredient.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class Recipe {
|
||||
int id;
|
||||
int homegroup;
|
||||
String name;
|
||||
List<Ingredient> ingredients;
|
||||
List<RecipeIngredient> ingredients;
|
||||
|
||||
Recipe(
|
||||
{required this.id,
|
||||
@@ -19,9 +19,9 @@ class Recipe {
|
||||
required this.homegroup});
|
||||
|
||||
factory Recipe.fromJson(Map<String, dynamic> json) {
|
||||
List<Ingredient> ingredients = [];
|
||||
List<RecipeIngredient> ingredients = [];
|
||||
for (dynamic ingredient in json["ingredients"]) {
|
||||
ingredients.add(Ingredient.fromJson(ingredient));
|
||||
ingredients.add(RecipeIngredient.fromJson(ingredient));
|
||||
}
|
||||
return Recipe(
|
||||
id: json["id"] as int,
|
||||
@@ -56,10 +56,13 @@ class Recipe {
|
||||
for (int recipeID in group.recipes) {
|
||||
Recipe? recipe = await Recipe.get(recipeID);
|
||||
if (recipe != null) {
|
||||
// TODO: implement sorted insert
|
||||
recipes.add(recipe);
|
||||
}
|
||||
}
|
||||
|
||||
recipes.sort(((a, b) => a.name.compareTo(b.name)));
|
||||
|
||||
return recipes;
|
||||
}
|
||||
|
||||
@@ -78,4 +81,19 @@ class Recipe {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<bool> delete() async {
|
||||
String requestURL = "$baseURL/api/recipes/$id/";
|
||||
String token = TokenSingleton().getToken();
|
||||
final http.Response response = await http.delete(
|
||||
Uri.parse(requestURL),
|
||||
headers: {"Authorization": "Token $token"},
|
||||
);
|
||||
|
||||
if (response.statusCode == 204) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
71
one_trip/lib/api/models/recipeingredient.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:one_trip/api/auth.dart';
|
||||
import 'package:one_trip/api/consts.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class RecipeIngredient {
|
||||
int id;
|
||||
String name;
|
||||
int recipe;
|
||||
|
||||
RecipeIngredient({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.recipe,
|
||||
});
|
||||
|
||||
factory RecipeIngredient.fromJson(Map<String, dynamic> json) {
|
||||
return RecipeIngredient(
|
||||
id: json["id"] as int,
|
||||
name: json["name"] as String,
|
||||
recipe: json["recipe"] as int,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<RecipeIngredient?> create(String name, int recipeID) async {
|
||||
const String requestURL = "$baseURL/api/recipeingredients/";
|
||||
String token = TokenSingleton().getToken();
|
||||
http.Response response = await http.post(
|
||||
Uri.parse(requestURL),
|
||||
headers: {"Authorization": "Token $token"},
|
||||
body: {
|
||||
"name": name,
|
||||
"recipe": "$recipeID",
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 201) {
|
||||
return RecipeIngredient.fromJson(jsonDecode(response.body));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<RecipeIngredient?> patch(String name) async {
|
||||
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});
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return RecipeIngredient.fromJson(jsonDecode(response.body));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<bool> delete() async {
|
||||
String requestURL = "$baseURL/api/recipeingredients/$id/";
|
||||
String token = TokenSingleton().getToken();
|
||||
http.Response response = await http.delete(Uri.parse(requestURL),
|
||||
headers: {"Authorization": "Token $token"});
|
||||
|
||||
if (response.statusCode == 204) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
52
one_trip/lib/pages/list_page/list_page.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
// 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';
|
||||
|
||||
// class RecipesPage extends StatefulWidget {
|
||||
// const RecipesPage({super.key});
|
||||
|
||||
// @override
|
||||
// State<RecipesPage> createState() => _RecipesPageState();
|
||||
// }
|
||||
|
||||
// class _RecipesPageState extends State<RecipesPage> {
|
||||
// late Future<List<Recipe>> _recipes;
|
||||
// late User _userInfo;
|
||||
|
||||
// Future<List<Recipe>> _fetchList() async {
|
||||
// User? userInfo = await User.getMe();
|
||||
// if (userInfo == null || userInfo.homegroup == null) {
|
||||
// return [];
|
||||
// }
|
||||
// _userInfo = userInfo;
|
||||
|
||||
// List<Recipe> recipes = await Recipe.getList(_userInfo.homegroup!);
|
||||
// return recipes;
|
||||
// }
|
||||
|
||||
// @override
|
||||
// void initState() {
|
||||
// super.initState();
|
||||
// _recipes = _fetchList();
|
||||
// }
|
||||
|
||||
// @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());
|
||||
// }
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
@@ -16,6 +16,12 @@ class _InviteHomegroupDialogState extends State<InviteHomegroupDialog> {
|
||||
ListViewState _listState = ListViewState.inactive;
|
||||
List<int> selectedIDs = [];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
|
||||
@@ -32,20 +32,17 @@ class ProfileCard extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
backgroundColor: Colors.black,
|
||||
radius: 42,
|
||||
child: CircleAvatar(
|
||||
radius: 40,
|
||||
backgroundImage: userInfo.imageUrl != null
|
||||
? NetworkImage(userInfo.imageUrl!)
|
||||
: Image(
|
||||
: const Image(
|
||||
image: Svg('assets/images/person.svg',
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onPrimaryContainer),
|
||||
color: Colors.black),
|
||||
).image,
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.primaryContainer,
|
||||
backgroundColor: Colors.white,
|
||||
// https://github.com/flutter/flutter/issues/42901#issuecomment-708050484
|
||||
child: Material(
|
||||
shape: const CircleBorder(),
|
||||
|
||||
@@ -16,16 +16,14 @@ class SmallUserChip extends StatelessWidget {
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: baseRadius,
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
backgroundColor: Colors.black,
|
||||
child: CircleAvatar(
|
||||
radius: baseRadius - 2,
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
backgroundColor: Colors.white,
|
||||
backgroundImage: user.imageUrl != null
|
||||
? NetworkImage(user.imageUrl!)
|
||||
: Image(
|
||||
image: Svg('assets/images/person.svg',
|
||||
color:
|
||||
Theme.of(context).colorScheme.onPrimaryContainer),
|
||||
: const Image(
|
||||
image: Svg('assets/images/person.svg', color: Colors.black),
|
||||
).image,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:one_trip/api/models/homegroup.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';
|
||||
@@ -14,16 +13,17 @@ class RecipesPage extends StatefulWidget {
|
||||
|
||||
class _RecipesPageState extends State<RecipesPage> {
|
||||
late Future<List<Recipe>> _recipes;
|
||||
late User _userInfo;
|
||||
User? _userInfo;
|
||||
|
||||
Future<List<Recipe>> _fetchList() async {
|
||||
User? userInfo = await User.getMe();
|
||||
_userInfo = userInfo;
|
||||
|
||||
if (userInfo == null || userInfo.homegroup == null) {
|
||||
return [];
|
||||
}
|
||||
_userInfo = userInfo;
|
||||
|
||||
List<Recipe> recipes = await Recipe.getList(_userInfo.homegroup!);
|
||||
List<Recipe> recipes = await Recipe.getList(userInfo.homegroup!);
|
||||
return recipes;
|
||||
}
|
||||
|
||||
@@ -42,8 +42,18 @@ class _RecipesPageState extends State<RecipesPage> {
|
||||
return Text(snapshot.error.toString());
|
||||
} else if (snapshot.hasData &&
|
||||
snapshot.connectionState == ConnectionState.done) {
|
||||
return RecipeList(
|
||||
recipes: snapshot.data!, homegroup: _userInfo.homegroup!);
|
||||
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 {
|
||||
return RecipeList(
|
||||
recipes: snapshot.data!, homegroup: _userInfo!.homegroup!);
|
||||
}
|
||||
} else {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
@@ -63,6 +73,8 @@ class RecipeList extends StatefulWidget {
|
||||
|
||||
class _RecipeListState extends State<RecipeList> {
|
||||
int? _expandedCard;
|
||||
late List<Recipe> _recipes;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
void showError(String text) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -78,16 +90,30 @@ class _RecipeListState extends State<RecipeList> {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_recipes = widget.recipes;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
ListView.separated(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: widget.recipes.length,
|
||||
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: widget.recipes[index],
|
||||
recipe: _recipes[index],
|
||||
isExpanded: _expandedCard == index,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
@@ -98,6 +124,29 @@ class _RecipeListState extends State<RecipeList> {
|
||||
}
|
||||
});
|
||||
},
|
||||
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(() {
|
||||
_recipes[index] = newRecipe;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
Align(
|
||||
@@ -119,6 +168,16 @@ class _RecipeListState extends State<RecipeList> {
|
||||
}
|
||||
|
||||
Recipe? created = await Recipe.create(name, widget.homegroup);
|
||||
if (created != null) {
|
||||
setState(() {
|
||||
_recipes.insert(0, created);
|
||||
_expandedCard = 0;
|
||||
});
|
||||
|
||||
_scrollController.animateTo(0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.linear);
|
||||
}
|
||||
},
|
||||
label: Row(
|
||||
children: const [Icon(Icons.note_add), Text("New Recipe")],
|
||||
|
||||
@@ -1,131 +1,282 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:one_trip/api/models/ingredient.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';
|
||||
|
||||
class RecipeCard extends StatelessWidget {
|
||||
class RecipeCard extends StatefulWidget {
|
||||
final Recipe recipe;
|
||||
final bool isExpanded;
|
||||
final Function() onTap;
|
||||
final Function() onDismiss;
|
||||
final Function() onChanged;
|
||||
const RecipeCard({
|
||||
super.key,
|
||||
required this.recipe,
|
||||
required this.isExpanded,
|
||||
required this.onTap,
|
||||
required this.onDismiss,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RecipeCard> createState() => _RecipeCardState();
|
||||
}
|
||||
|
||||
class _RecipeCardState extends State<RecipeCard> with TickerProviderStateMixin {
|
||||
double dismissAmount = 0.0;
|
||||
bool willDismiss = false;
|
||||
|
||||
late final AnimationController _verticalController = AnimationController(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
vsync: this,
|
||||
);
|
||||
late final Animation<double> _verticalAnimation = CurvedAnimation(
|
||||
parent: _verticalController,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
|
||||
late final AnimationController _rotationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
vsync: this,
|
||||
upperBound: 0.5,
|
||||
);
|
||||
late final Animation<double> _rotationAnimation = CurvedAnimation(
|
||||
parent: _rotationController,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_verticalController.dispose();
|
||||
_rotationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant RecipeCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!oldWidget.isExpanded && widget.isExpanded) {
|
||||
_verticalController.forward();
|
||||
_rotationController.forward();
|
||||
}
|
||||
|
||||
if (oldWidget.isExpanded && !widget.isExpanded) {
|
||||
_verticalController.reverse();
|
||||
_rotationController.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Card(
|
||||
elevation: 10,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: const Radius.circular(10),
|
||||
bottom: isExpanded ? Radius.zero : const Radius.circular(10)),
|
||||
),
|
||||
margin: EdgeInsets.zero,
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
recipe.name,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
AnimatedRotation(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
turns: isExpanded ? 0.5 : 0,
|
||||
child: const Icon(Icons.expand_more, size: 30),
|
||||
),
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
elevation: 10,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Dismissible(
|
||||
direction: widget.isExpanded
|
||||
? DismissDirection.none
|
||||
: DismissDirection.endToStart,
|
||||
key: Key("${widget.recipe.id}"),
|
||||
onDismissed: (direction) => widget.onDismiss(),
|
||||
onUpdate: (details) {
|
||||
setState(() {
|
||||
dismissAmount = details.progress;
|
||||
willDismiss = details.reached;
|
||||
});
|
||||
},
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: isExpanded
|
||||
? IngredientSection(ingredients: recipe.ingredients)
|
||||
: const SizedBox(),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: SizedBox(
|
||||
width: 45,
|
||||
child: Icon(
|
||||
Icons.delete,
|
||||
size: min(27.5 * dismissAmount + 20, 35),
|
||||
color: willDismiss ? Colors.red : Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Container(
|
||||
color: Theme.of(context).cardColor,
|
||||
margin: EdgeInsets.zero,
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.recipe.name,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
RotationTransition(
|
||||
turns: _rotationAnimation,
|
||||
child: const Icon(Icons.expand_more, size: 30))
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizeTransition(
|
||||
sizeFactor: _verticalAnimation,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
IngredientSection(
|
||||
ingredients: widget.recipe.ingredients,
|
||||
onChanged: widget.onChanged,
|
||||
),
|
||||
ElevatedButton(
|
||||
style: bottomButtonStyle.copyWith(
|
||||
shape: const MaterialStatePropertyAll(
|
||||
RoundedRectangleBorder())),
|
||||
onPressed: () async {
|
||||
String? name = await textEntryDialog(
|
||||
context, "Ingredient Name", "Ingredient");
|
||||
|
||||
if (name == null || name == "") {
|
||||
return;
|
||||
}
|
||||
|
||||
RecipeIngredient? ingredient =
|
||||
await RecipeIngredient.create(name, widget.recipe.id);
|
||||
if (ingredient != null) {
|
||||
widget.onChanged();
|
||||
}
|
||||
},
|
||||
child: const Text("Add Ingredient"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: isExpanded
|
||||
? ElevatedButton(
|
||||
style: bottomButtonStyle,
|
||||
onPressed: () async {
|
||||
String? name = await textEntryDialog(
|
||||
context, "Ingredient Name", "Ingredient");
|
||||
},
|
||||
child: const Text("Add Ingredient"),
|
||||
)
|
||||
: const SizedBox(),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class IngredientSection extends StatelessWidget {
|
||||
final List<Ingredient> ingredients;
|
||||
const IngredientSection({super.key, required this.ingredients});
|
||||
class IngredientSection extends StatefulWidget {
|
||||
final List<RecipeIngredient> ingredients;
|
||||
final Function() onChanged;
|
||||
const IngredientSection(
|
||||
{super.key, required this.ingredients, required this.onChanged});
|
||||
|
||||
@override
|
||||
State<IngredientSection> createState() => _IngredientSectionState();
|
||||
}
|
||||
|
||||
class _IngredientSectionState extends State<IngredientSection> {
|
||||
double dismissAmount = 0.0;
|
||||
bool willDismiss = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TextStyle ingredientStyle = Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge!
|
||||
.copyWith(color: Theme.of(context).colorScheme.onSurface);
|
||||
|
||||
return Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: (ingredients.length / 2).ceil(),
|
||||
child: ListView.builder(
|
||||
padding: widget.ingredients.isEmpty
|
||||
? EdgeInsets.zero
|
||||
: const EdgeInsets.all(8),
|
||||
itemCount: widget.ingredients.length,
|
||||
shrinkWrap: true,
|
||||
separatorBuilder: (context, index) => Divider(
|
||||
color: index % 2 == 0
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
if (ingredients.length % 2 == 0 ||
|
||||
index * 2 <= ingredients.length - 2) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
ingredients[index * 2].name,
|
||||
style: ingredientStyle,
|
||||
return Column(
|
||||
children: [
|
||||
Dismissible(
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: SizedBox(
|
||||
width: 40,
|
||||
child: Icon(
|
||||
Icons.delete,
|
||||
size: min(27.5 * dismissAmount + 20, 35),
|
||||
color: willDismiss ? Colors.red : Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(ingredients[index * 2 + 1].name,
|
||||
style: ingredientStyle),
|
||||
)
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Row(
|
||||
children: [
|
||||
Text(ingredients[index * 2].name, style: ingredientStyle),
|
||||
],
|
||||
);
|
||||
}
|
||||
onUpdate: (details) {
|
||||
setState(() {
|
||||
dismissAmount = details.progress;
|
||||
willDismiss = details.reached;
|
||||
});
|
||||
},
|
||||
onDismissed: (direction) async {
|
||||
bool success = await widget.ingredients[index].delete();
|
||||
if (success) {
|
||||
widget.onChanged();
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.ingredients[index].name,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
)),
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
String? name = await textEntryDialog(
|
||||
context, "Change Ingredient Name", "Ingredient",
|
||||
defaultValue: widget.ingredients[index].name);
|
||||
|
||||
if (name == null || name == "") {
|
||||
return;
|
||||
}
|
||||
|
||||
RecipeIngredient? changed =
|
||||
await widget.ingredients[index].patch(name);
|
||||
if (changed != null) {
|
||||
widget.onChanged();
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.edit)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: index % 2 == 0
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.error)
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -119,6 +119,15 @@ class ColorPage extends StatelessWidget {
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
)),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
color: Theme.of(context).canvasColor,
|
||||
child: Center(
|
||||
child: Text(
|
||||
"Canvas",
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
)),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -172,6 +172,13 @@ class _LoginFormState extends State<LoginForm> {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -294,6 +301,15 @@ class _SignupFormState extends State<SignupForm> {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
_firstnameController.dispose();
|
||||
_lastnameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Form(
|
||||
|
||||
@@ -55,6 +55,12 @@ class _PaginationListViewState extends State<PaginationListView> {
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
@@ -14,6 +14,12 @@ class TextEntryForm extends StatefulWidget {
|
||||
class _TextEntryFormState extends State<TextEntryForm> {
|
||||
late TextEditingController _textController;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_textController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -36,7 +42,11 @@ class _TextEntryFormState extends State<TextEntryForm> {
|
||||
),
|
||||
const Divider(),
|
||||
TextFormField(
|
||||
autofocus: true,
|
||||
controller: _textController,
|
||||
onFieldSubmitted: (value) {
|
||||
Navigator.pop(context, value);
|
||||
},
|
||||
decoration: InputDecoration(hintText: widget.label),
|
||||
),
|
||||
Padding(
|
||||
|
||||
@@ -1,68 +1,68 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "16x16",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_16.png",
|
||||
"scale" : "1x"
|
||||
"info": {
|
||||
"version": 1,
|
||||
"author": "xcode"
|
||||
},
|
||||
{
|
||||
"size" : "16x16",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_32.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "32x32",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_32.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "32x32",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_64.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "128x128",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_128.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "128x128",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_256.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "256x256",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_256.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "256x256",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_512.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "512x512",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_512.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "512x512",
|
||||
"idiom" : "mac",
|
||||
"filename" : "app_icon_1024.png",
|
||||
"scale" : "2x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
"images": [
|
||||
{
|
||||
"size": "16x16",
|
||||
"idiom": "mac",
|
||||
"filename": "app_icon_16.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"size": "16x16",
|
||||
"idiom": "mac",
|
||||
"filename": "app_icon_32.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"size": "32x32",
|
||||
"idiom": "mac",
|
||||
"filename": "app_icon_32.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"size": "32x32",
|
||||
"idiom": "mac",
|
||||
"filename": "app_icon_64.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"size": "128x128",
|
||||
"idiom": "mac",
|
||||
"filename": "app_icon_128.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"size": "128x128",
|
||||
"idiom": "mac",
|
||||
"filename": "app_icon_256.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"size": "256x256",
|
||||
"idiom": "mac",
|
||||
"filename": "app_icon_256.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"size": "256x256",
|
||||
"idiom": "mac",
|
||||
"filename": "app_icon_512.png",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"size": "512x512",
|
||||
"idiom": "mac",
|
||||
"filename": "app_icon_512.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"size": "512x512",
|
||||
"idiom": "mac",
|
||||
"filename": "app_icon_1024.png",
|
||||
"scale": "2x"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 520 B After Width: | Height: | Size: 780 B |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 4.8 KiB |
@@ -108,5 +108,8 @@ flutter_icons:
|
||||
image_path: "assets/icons/desktop.png"
|
||||
icon_size: 256
|
||||
macos:
|
||||
generate: true
|
||||
image_path: "assets/icons/desktop.png"
|
||||
web:
|
||||
generate: true
|
||||
image_path: "assets/icons/desktop.png"
|
||||
|
Before Width: | Height: | Size: 917 B After Width: | Height: | Size: 780 B |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 40 KiB |
@@ -14,7 +14,7 @@
|
||||
This is a placeholder for base href that will be replaced by the value of
|
||||
the `--base-href` argument provided to `flutter build`.
|
||||
-->
|
||||
<base href="$FLUTTER_BASE_HREF">
|
||||
<base href="/app/">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
|
||||
@@ -32,4 +32,4 @@
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 19 KiB |