add websock functionality

This commit is contained in:
Alexander Laevens
2022-11-30 02:52:56 -07:00
parent 34edcd53cb
commit 339b0c6ad9
41 changed files with 1028 additions and 189 deletions

View File

@@ -1,52 +1,305 @@
// import 'package:flutter/material.dart';
// import 'package:one_trip/api/models/recipe.dart';
// import 'package:one_trip/api/models/user.dart';
// import 'package:one_trip/pages/recipes_page/widgets/recipe_card_widget.dart';
// import 'package:one_trip/widgets/text_entry_dialog.dart';
import 'dart:convert';
import 'dart:io';
// class RecipesPage extends StatefulWidget {
// const RecipesPage({super.key});
import 'package:flutter/material.dart';
import 'package:one_trip/api/auth.dart';
import 'package:one_trip/api/consts.dart';
import 'package:one_trip/api/models/list.dart';
import 'package:one_trip/api/models/listingredient.dart';
import 'package:one_trip/api/models/user.dart';
import 'package:one_trip/pages/list_page/widgets/listrow.dart';
import 'package:one_trip/pages/list_page/widgets/search_recipes.dart';
import 'package:one_trip/widgets/text_entry_dialog.dart';
// @override
// State<RecipesPage> createState() => _RecipesPageState();
// }
class ListPage extends StatefulWidget {
const ListPage({super.key});
// class _RecipesPageState extends State<RecipesPage> {
// late Future<List<Recipe>> _recipes;
// late User _userInfo;
@override
State<ListPage> createState() => _ListPageState();
}
// Future<List<Recipe>> _fetchList() async {
// User? userInfo = await User.getMe();
// if (userInfo == null || userInfo.homegroup == null) {
// return [];
// }
// _userInfo = userInfo;
class _ListPageState extends State<ListPage> {
ShoppingList? _list;
late Future<bool> _isLoaded;
User? _userInfo;
WebSocket? _ws;
// List<Recipe> recipes = await Recipe.getList(_userInfo.homegroup!);
// return recipes;
// }
Future<bool> _fetchList() async {
User? userInfo = await User.getMe();
_userInfo = userInfo;
// @override
// void initState() {
// super.initState();
// _recipes = _fetchList();
// }
if (userInfo == null || userInfo.homegroup == null) {
return false;
}
// @override
// Widget build(BuildContext context) {
// return FutureBuilder(
// future: _recipes,
// builder: (context, snapshot) {
// if (snapshot.hasError) {
// return Text(snapshot.error.toString());
// } else if (snapshot.hasData &&
// snapshot.connectionState == ConnectionState.done) {
// return RecipeList(
// recipes: snapshot.data!, homegroup: _userInfo.homegroup!);
// } else {
// return const Center(child: CircularProgressIndicator());
// }
// },
// );
// }
// }
_list = await ShoppingList.get(userInfo.homegroup!);
return true;
}
void _connectSocket() async {
String token = TokenSingleton().getToken();
_ws = await WebSocket.connect("$baseWsURL/ws/",
headers: {"Authorization": "Token $token"});
if (_ws == null) {
return;
}
_ws!.listen((event) async {
Map<String, dynamic> json = jsonDecode(event);
if (json.keys.contains("type") && json["type"] == "recommend_update") {
if (json["hash"] != _list.hashCode) {
ShoppingList? newList = await ShoppingList.get(_list!.homegroup);
if (newList != null) {
setState(() {
_list = newList;
});
}
}
}
});
}
void _sendUpdate() async {
if (_ws == null) {
return;
}
_ws!.add(jsonEncode({"type": "broadcast_update", "hash": _list.hashCode}));
}
@override
void dispose() {
if (_ws != null) {
_ws!.close();
}
super.dispose();
}
@override
void initState() {
super.initState();
_isLoaded = _fetchList();
_connectSocket();
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _isLoaded,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Text(snapshot.error.toString());
} else if (snapshot.hasData &&
snapshot.connectionState == ConnectionState.done) {
if (_userInfo == null) {
return const Center(
child: Text("Could not load user, try logging in again..."),
);
} else if (_userInfo!.homegroup == null) {
return const Center(
child: Text("You must be in a homegroup to use this feature"),
);
} else if (_list == null) {
return const Center(
child: Text("Issue loading list"),
);
} else {
return ListArea(
list: _list!,
onAddOne: () async {
String? itemName =
await textEntryDialog(context, "Item Name", "Item");
if (itemName == null || itemName == "") {
return;
}
ListIngredient? newIngredient =
await ListIngredient.create(itemName, _list!.homegroup);
if (newIngredient == null) {
return;
}
ShoppingList? newList =
await ShoppingList.get(_list!.homegroup);
if (newList != null) {
setState(() {
_list = newList;
});
}
_sendUpdate();
},
onAddMany: () async {
List<int>? selectedIDs = await searchRecipesDialog(context);
if (selectedIDs == null) {
return;
}
ShoppingList tempList = _list!;
for (int id in selectedIDs) {
ShoppingList? newList = await tempList.addRecipe(id);
if (newList != null) {
tempList = newList;
}
}
setState(() {
_list = tempList;
});
_sendUpdate();
},
onDelete: (ingredient) async {
bool success = await ingredient.delete();
if (success) {
// ShoppingList? newList =
// await _list!.patch(updates: _list!.updates + 1);
ShoppingList? newList =
await ShoppingList.get(_list!.homegroup);
setState(() {
_list = newList;
});
_sendUpdate();
}
return success;
},
onUpdate: (ingredient, {inCart, name}) async {
ListIngredient? updated =
await ingredient.patch(name: name, inCart: inCart);
if (updated != null) {
ShoppingList? newList =
await ShoppingList.get(_list!.homegroup);
setState(() {
_list = newList;
});
_sendUpdate();
}
},
onClear: () async {
ShoppingList? newList = await _list!.clear();
if (newList != null) {
setState(() {
_list = newList;
});
}
_sendUpdate();
},
);
}
} else {
return const Center(child: CircularProgressIndicator());
}
},
);
}
}
class ListArea extends StatelessWidget {
final ShoppingList list;
final Function() onAddOne;
final Function() onAddMany;
final Function() onClear;
final Future<bool> Function(ListIngredient ingredient) onDelete;
final Function(ListIngredient ingredient, {String? name, bool? inCart})
onUpdate;
const ListArea({
super.key,
required this.list,
required this.onAddOne,
required this.onAddMany,
required this.onDelete,
required this.onUpdate,
required this.onClear,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: ListView.separated(
key: UniqueKey(),
itemCount: list.ingredients.length,
padding: const EdgeInsets.all(8),
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
return ListRow(
ingredient: list.ingredients[index],
onToggle: (value) {
onUpdate(list.ingredients[index], inCart: value);
},
apiRemove: (ingredient) async => await onDelete(ingredient),
index: index,
);
},
),
),
LayoutBuilder(
builder: (context, constraints) {
ButtonStyle buttonStyle = ButtonStyle(
fixedSize: MaterialStatePropertyAll(
Size(constraints.maxWidth / 3, 45),
),
shape: MaterialStateProperty.all(
const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
bottom: Radius.zero, top: Radius.circular(10)),
),
),
padding: const MaterialStatePropertyAll(EdgeInsets.zero));
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ElevatedButton(
style: buttonStyle,
onPressed: () => onAddMany(),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [Icon(Icons.post_add), Text("Add Recipes")],
),
),
ElevatedButton(
style: buttonStyle,
onPressed: () => onAddOne(),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [Icon(Icons.add), Text("Add Item")],
),
),
ElevatedButton(
style: buttonStyle.copyWith(
backgroundColor: MaterialStatePropertyAll(
Theme.of(context).colorScheme.error),
foregroundColor: MaterialStatePropertyAll(
Theme.of(context).colorScheme.onError),
),
onPressed: () => onClear(),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [Icon(Icons.delete), Text("Clear List")],
),
)
],
);
},
)
],
);
}
}

View File

@@ -0,0 +1,85 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:one_trip/api/models/listingredient.dart';
class ListRow extends StatefulWidget {
final ListIngredient ingredient;
final Future<bool> Function(ListIngredient ingredient) apiRemove;
final Function(bool value) onToggle;
final int index;
const ListRow({
super.key,
required this.ingredient,
required this.onToggle,
required this.index,
required this.apiRemove,
});
@override
State<ListRow> createState() => _ListRowState();
}
class _ListRowState extends State<ListRow> {
double dismissAmount = 0.0;
bool willDismiss = false;
final UniqueKey key = UniqueKey();
@override
void didUpdateWidget(covariant ListRow oldWidget) {
super.didUpdateWidget(oldWidget);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => widget.onToggle(!widget.ingredient.inCart),
child: Dismissible(
key: key,
direction: DismissDirection.endToStart,
onUpdate: (details) => setState(() {
dismissAmount = details.progress;
willDismiss = details.reached;
}),
confirmDismiss: (direction) async =>
await widget.apiRemove(widget.ingredient),
background: Container(
color: Color.lerp(
Colors.transparent, Colors.red, min(dismissAmount * 2.5, 1)),
child: Align(
alignment: Alignment.centerRight,
child: SizedBox(
width: 45,
child: Icon(
Icons.delete,
size: min(27.5 * dismissAmount + 20, 35),
color: Colors.white,
),
),
),
),
child: Row(
children: [
Checkbox(
value: widget.ingredient.inCart,
onChanged: (value) => widget.onToggle(value!),
),
Expanded(
child: Text(
// _ingredient.name,
widget.ingredient.name,
style: Theme.of(context).textTheme.titleMedium!.copyWith(
decoration: widget.ingredient.inCart
? TextDecoration.lineThrough
: null,
color: widget.ingredient.inCart ? Colors.green : null,
),
),
),
// IconButton(onPressed: () {}, icon: const Icon(Icons.edit)),
],
),
),
);
}
}

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,
itemBuilder: (context, data) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
setState(() {
_listState = ListViewState.inUse;
if (selectedIDs.contains(data.id)) {
selectedIDs.remove(data.id);
} else {
selectedIDs.add(data.id);
}
});
},
child: Container(
padding: const EdgeInsets.all(4),
color: selectedIDs.contains(data.id)
? Theme.of(context).colorScheme.secondary
: null,
child: Text(data.name),
),
);
},
seperatorBuilder: (context, data) {
return const Divider();
},
dataProvider: (int page) async {
// SearchResult<SimpleUser> result =
// await SimpleUser.search(_searchController.text, page);
// List<dynamic> users = List<dynamic>.from(result.results);
// if (result.next == null) {
// users.add(null);
// }
// return users;
SearchResult<Recipe> result =
await Recipe.search(_searchController.text, page);
List<dynamic> recipes =
List<dynamic>.from(result.results);
if (result.next == null) {
recipes.add(null);
}
return recipes;
},
),
);
},
),
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
onPressed: () => Navigator.pop(context),
child: const Text("Cancel"),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, selectedIDs),
child: const Text("Done")),
],
),
)
],
),
),
);
}
}
Future<List<int>?> searchRecipesDialog(BuildContext context) async {
List<int>? selectedIDs = await showDialog(
context: context,
builder: (context) {
return Dialog(
child: ScrollConfiguration(
behavior: MyBehavior(), child: const SearchRecipesDialog()),
);
},
);
return selectedIDs;
}

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

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

@@ -85,6 +85,7 @@ class _RecipeCardState extends State<RecipeCard> with TickerProviderStateMixin {
: DismissDirection.endToStart,
key: Key("${widget.recipe.id}"),
onDismissed: (direction) => widget.onDismiss(),
confirmDismiss: (direction) => widget.recipe.delete(),
onUpdate: (details) {
setState(() {
dismissAmount = details.progress;
@@ -92,16 +93,8 @@ class _RecipeCardState extends State<RecipeCard> with TickerProviderStateMixin {
});
},
background: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Color.fromARGB(255, 255, 0, 0),
Color.fromARGB(255, 255, 170, 170),
],
),
),
color: Color.lerp(Colors.transparent, Colors.red,
min(dismissAmount * 2.5, 1)),
child: Align(
alignment: Alignment.centerRight,
child: SizedBox(
@@ -109,7 +102,7 @@ class _RecipeCardState extends State<RecipeCard> with TickerProviderStateMixin {
child: Icon(
Icons.delete,
size: min(27.5 * dismissAmount + 20, 35),
color: willDismiss ? Colors.red : Colors.white,
color: Colors.white,
),
),
),
@@ -193,9 +186,11 @@ class _IngredientSectionState extends State<IngredientSection> {
@override
Widget build(BuildContext context) {
return Container(
return Material(
elevation: 10,
color: Theme.of(context).colorScheme.surface,
child: ListView.builder(
physics: const NeverScrollableScrollPhysics(),
padding: widget.ingredients.isEmpty
? EdgeInsets.zero
: const EdgeInsets.all(8),
@@ -208,16 +203,8 @@ class _IngredientSectionState extends State<IngredientSection> {
key: Key("${widget.ingredients[index].id}"),
direction: DismissDirection.endToStart,
background: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Color.fromARGB(255, 255, 0, 0),
Color.fromARGB(255, 255, 170, 170),
],
),
),
color: Color.lerp(Colors.transparent, Colors.red,
min(dismissAmount * 2.5, 1)),
child: Align(
alignment: Alignment.centerRight,
child: SizedBox(
@@ -225,7 +212,7 @@ class _IngredientSectionState extends State<IngredientSection> {
child: Icon(
Icons.delete,
size: min(27.5 * dismissAmount + 20, 35),
color: willDismiss ? Colors.red : Colors.white,
color: Colors.white,
),
),
),
@@ -236,11 +223,10 @@ class _IngredientSectionState extends State<IngredientSection> {
willDismiss = details.reached;
});
},
confirmDismiss: (direction) async =>
await widget.ingredients[index].delete(),
onDismissed: (direction) async {
bool success = await widget.ingredients[index].delete();
if (success) {
widget.onChanged();
}
widget.onChanged();
},
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
@@ -251,22 +237,23 @@ class _IngredientSectionState extends State<IngredientSection> {
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 {
String? name = await textEntryDialog(
context, "Change Ingredient Name", "Ingredient",
defaultValue: widget.ingredients[index].name);
if (name == null || name == "") {
return;
}
if (name == null || 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);
if (changed != null) {
widget.onChanged();
}
},
icon: const Icon(Icons.edit),
),
],
),
),