diff --git a/lib/main.dart b/lib/main.dart index a997fee..89b497f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,10 +2,10 @@ import 'package:flutter/material.dart'; import 'package:smart_chef/routes/routes.dart'; void main() { - runApp(MyApp()); + runApp(SmartChef()); } -class MyApp extends StatelessWidget { +class SmartChef extends StatelessWidget { // This widget is the root of your application. @override Widget build(BuildContext context) { @@ -15,6 +15,9 @@ class MyApp extends StatelessWidget { restorationScopeId: 'root', theme: ThemeData(), routes: Routes.getroutes, + onGenerateRoute: (settings) { + return Routes.generateRoute(settings); + }, ); } } \ No newline at end of file diff --git a/lib/routes/routes.dart b/lib/routes/routes.dart index daa3f52..c41bb75 100644 --- a/lib/routes/routes.dart +++ b/lib/routes/routes.dart @@ -16,7 +16,6 @@ class Routes { static const String ingredientsScreen = '/food'; static const String individualIngredientScreen = '/food/food'; static const String addIngredientScreen = '/food/add'; - static const String editIngredientScreen = '/food/edit'; static const String shoppingCartScreen = '/cart'; @@ -35,9 +34,7 @@ class Routes { recipeScreen: (context) => RecipeScreen(), ingredientsScreen: (context) => IngredientsScreen(), - individualIngredientScreen: (context) => IngredientPage(), addIngredientScreen: (context) => AddIngredientPage(), - editIngredientScreen: (context) => EditIngredientPage(), shoppingCartScreen: (context) => ShoppingCartScreen(), @@ -45,4 +42,17 @@ class Routes { editUserProfileScreen: (context) => EditUserProfilePage(), editPasswordScreen: (context) => EditPasswordPage(), }; + + static Route generateRoute(RouteSettings settings) { + switch (settings.name) { + case '/food/food': + var arguments = settings.arguments; + if (arguments is IngredientArguments) + return MaterialPageRoute(builder: (context) => IngredientPage(arguments)); + else + return MaterialPageRoute(builder: (context) => StartupScreen()); + default: + return MaterialPageRoute(builder: (context) => StartupScreen()); + } + } } diff --git a/lib/screens/IngredientScreen.dart b/lib/screens/IngredientScreen.dart index a557e9a..a0cacac 100644 --- a/lib/screens/IngredientScreen.dart +++ b/lib/screens/IngredientScreen.dart @@ -1,11 +1,16 @@ import 'dart:convert'; - +import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; +import 'package:intl/intl.dart'; +import 'package:smart_chef/screens/LoadingOverlay.dart'; import 'package:smart_chef/utils/APIutils.dart'; import 'package:smart_chef/utils/authAPI.dart'; import 'package:smart_chef/utils/colors.dart'; import 'package:smart_chef/utils/globals.dart'; +import 'package:smart_chef/utils/ingredientAPI.dart'; +import 'package:smart_chef/utils/ingredientData.dart'; +import 'package:smart_chef/utils/inventoryAPI.dart'; import 'package:smart_chef/utils/userAPI.dart'; class IngredientsScreen extends StatefulWidget { @@ -34,14 +39,32 @@ class _IngredientsPageState extends State { @override void initState() { super.initState(); + inventoryScroll = ScrollController()..addListener(_scrollListener); + } + + @override + void dispose() { + inventoryScroll.removeListener(_scrollListener); + super.dispose(); } - Icon leadingIcon = const Icon(Icons.search, color: Colors.black); + late List body; + Map> userInventory = {}; + late ScrollController inventoryScroll; + String errorMessage = 'You have no items in your inventory!'; + + Future makeTiles() async { + userInventory = await retrieveInventory(_groupValue); + body = await BuildTiles(); + } + + Icon leadingIcon = const Icon(Icons.search, color: black); Widget searchBar = const Text('SmartChef', style: TextStyle(fontSize: 24, color: mainScheme)); final _search = TextEditingController(); int _groupValue = 0; + int itemsToDisplay = 30; String sorted = 'Sorted by Expiration Date(Oldest First)'; List checkListItems = [ @@ -79,15 +102,19 @@ class _IngredientsPageState extends State { @override Widget build(BuildContext context) { + double bodyHeight = MediaQuery.of(context).size.height - + bottomRowHeight - + MediaQuery.of(context).padding.top - + AppBar().preferredSize.height; return Scaffold( appBar: AppBar( title: searchBar, centerTitle: true, - backgroundColor: Colors.white, + backgroundColor: white, leading: IconButton( onPressed: () { if (leadingIcon.icon == Icons.search) { - leadingIcon = const Icon(Icons.cancel, color: Colors.white); + leadingIcon = const Icon(Icons.cancel, color: white); searchBar = Container( width: 300, height: 35, @@ -101,18 +128,35 @@ class _IngredientsPageState extends State { SizedBox( width: 230, height: MediaQuery.of(context).size.height, - child: searchField, + child: TextField( + maxLines: 1, + decoration: const InputDecoration.collapsed( + hintText: 'Search...', + hintStyle: TextStyle( + color: searchFieldText, + fontSize: 18, + ), + ), + style: const TextStyle( + color: searchFieldText, + fontSize: 18, + ), + textInputAction: TextInputAction.done, + onChanged: (query) { + // TODO(15): Dynamic search + }, + ), ), const Icon( Icons.search, - color: Colors.black, + color: black, size: topBarIconSize, ), ], ), ); } else { - leadingIcon = const Icon(Icons.search, color: Colors.black); + leadingIcon = const Icon(Icons.search, color: black); searchBar = const Text('SmartChef', style: TextStyle(fontSize: 24, color: mainScheme)); } @@ -122,11 +166,96 @@ class _IngredientsPageState extends State { iconSize: topBarIconSize + 5, ), actions: [ + IconButton( + icon: const Icon( + Icons.clear, + color: Colors.red, + ), + iconSize: topBarIconSize, + onPressed: () async { + bool delete = false; + await showDialog( + context: context, + barrierDismissible: true, + builder: (context) { + return AlertDialog( + title: const Text( + 'Are you sure you want to clear your inventory?'), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + elevation: 15, + actions: [ + TextButton( + onPressed: () { + delete = false; + Navigator.pop(context); + }, + child: const Text( + 'Cancel', + style: TextStyle(color: Colors.red, fontSize: 18), + ), + ), + TextButton( + onPressed: () { + delete = true; + Navigator.pop(context); + }, + child: const Text( + 'Yes', + style: TextStyle(color: Colors.red, fontSize: 18), + ), + ) + ], + ); + }, + ); + + if (!delete) { + return; + } + + try { + for (var cat in userInventory.keys) { + if (userInventory[cat]!.length == 0) { + continue; + } + for (var ingreds in userInventory[cat]!) { + print(ingreds.toString()); + final res = await Inventory.deleteIngredientfromInventory( + ingreds.ID); + if (res.statusCode == 200) { + errorMessage = + 'Successfully cleared your inventory!'; + await messageDelay; + Navigator.pop(context); + } else { + int errorCode = await getDeleteError(res.statusCode); + if (errorCode == 2) { + final ret = await Inventory.deleteIngredientfromInventory( + ingreds.ID); + if (ret.statusCode == 200) { + errorMessage = + 'Successfully deleted ingredient from inventory!'; + await messageDelay; + Navigator.pop(context); + } else { + errorDialog(context); + } + } + } + } + } + } catch(e) { + print(e.toString()); + errorMessage = 'Cannot clear Inventory!'; + } + setState(() {}); + }), Builder(builder: (BuildContext context) { return IconButton( icon: const Icon( Icons.manage_search, - color: Colors.black, + color: black, ), iconSize: topBarIconSize + 10, onPressed: () => Scaffold.of(context).openEndDrawer(), @@ -135,7 +264,7 @@ class _IngredientsPageState extends State { ], ), endDrawer: Drawer( - backgroundColor: Colors.white, + backgroundColor: white, child: ListView( padding: EdgeInsets.zero, children: [ @@ -143,14 +272,14 @@ class _IngredientsPageState extends State { padding: const EdgeInsets.only(top: 15), decoration: BoxDecoration( border: Border( - bottom: BorderSide( - color: Colors.black.withOpacity(.2), width: 3))), + bottom: + BorderSide(color: black.withOpacity(.2), width: 3))), child: const DrawerHeader( child: Text( 'Sort by...', style: TextStyle( fontSize: 24, - color: Colors.black, + color: black, ), ), ), @@ -164,7 +293,7 @@ class _IngredientsPageState extends State { checkListItems[index]["title"], style: const TextStyle( fontSize: ingredientInfoFontSize, - color: Colors.black, + color: black, ), ), activeColor: mainScheme, @@ -186,7 +315,65 @@ class _IngredientsPageState extends State { onTap: () { FocusManager.instance.primaryFocus?.unfocus(); }, - child: BuildTiles(), + child: SingleChildScrollView( + controller: inventoryScroll, + child: Container( + width: MediaQuery.of(context).size.width, + height: bodyHeight, + decoration: const BoxDecoration(color: white), + child: Column( + children: [ + Container( + margin: const EdgeInsets.all(10), + width: MediaQuery.of(context).size.width, + decoration: const BoxDecoration( + color: Colors.transparent, + ), + child: Text(sorted, + style: const TextStyle( + fontSize: ingredientInfoFontSize, + color: black, + ))), + Expanded( + child: FutureBuilder( + future: makeTiles(), + builder: (BuildContext context, AsyncSnapshot snapshot) { + switch (snapshot.connectionState) { + case ConnectionState.active: + case ConnectionState.waiting: + return const CircularProgressIndicator(); + case ConnectionState.done: + if (snapshot.hasError) { + return Text('Error: $snapshot.error}'); + } + if (body.length == 0) { + return ListTile( + contentPadding: const EdgeInsets.all(15), + title: Text( + errorMessage, + style: const TextStyle( + fontSize: addIngredientPageTextSize, + color: searchFieldText, + ), + textAlign: TextAlign.center, + ), + ); + } + return ListView.builder( + itemCount: body.length, + itemBuilder: (context, index) { + return body[index]; + }, + ); + } + return const CircularProgressIndicator(); + }, + ), + ), + ], + ), + ), + ), ), extendBody: false, extendBodyBehindAppBar: false, @@ -195,9 +382,9 @@ class _IngredientsPageState extends State { height: bottomRowHeight, width: MediaQuery.of(context).size.width, decoration: BoxDecoration( - border: Border( - top: BorderSide(color: Colors.black.withOpacity(.2), width: 3)), - color: Colors.white, + border: + Border(top: BorderSide(color: black.withOpacity(.2), width: 3)), + color: white, ), child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -296,222 +483,497 @@ class _IngredientsPageState extends State { Navigator.restorablePushNamed(context, '/food/add'); }, backgroundColor: mainScheme, - foregroundColor: Colors.white, + foregroundColor: white, elevation: 25, - child: const Icon(Icons.add, size: 65, color: Colors.white), + child: const Icon(Icons.add, size: 65, color: white), ), floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, ); } - // TODO(17): function to get ingredients from api. Will also handle sorting - /*Future GrabIngredients(int sortBy) { - return; - }*/ + void _scrollListener() { + if (inventoryScroll.position.atEdge) { + bool isTop = inventoryScroll.position.pixels == 0; + if (!isTop) { + makeTiles(); + } + } + } - Widget BuildTiles() { - int numIngreds = 12; - List ingredients = []; + DateTime convertToDate(int secondEpoch) { + var date = DateTime.fromMicrosecondsSinceEpoch(secondEpoch * 1000); + return date; + } - if (numIngreds == 0) { - return Container( - margin: const EdgeInsets.only(top:20), - child: Column( - children: [ - Flexible( - child: Text( - 'You have no items in your inventory!\nPress the add button on the bottom right to start adding ingredients', - style: TextStyle( - fontSize: 24, color: Colors.grey[600], fontWeight: FontWeight.w600), - textAlign: TextAlign.center, - ) - ) - ] - ) - ); - } + int convertToEpoch(DateTime date) { + return date.toUtc().microsecondsSinceEpoch; + } - double bodyHeight = MediaQuery.of(context).size.height - - bottomRowHeight - - MediaQuery.of(context).padding.top - - AppBar().preferredSize.height; + Future>> retrieveInventory( + int sortBy) async { + bool reverse = false; + bool exDate = true; + bool cat = false; + bool alphabet = false; + String toSortBy = ''; + itemsToDisplay = 30; - double itemWidth = MediaQuery.of(context).size.width / 2 - 20; - double itemHeight = MediaQuery.of(context).size.height / 4 - 20; + Map> inventory = {}; - for (int i = 0; i < numIngreds; i++) { - ingredients.add( - GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () { - Navigator.restorablePushNamed(context, '/food/food'); - }, - child: IngredientTile(), - ), - ); + switch (sortBy) { + case 1: + reverse = true; + break; + case 2: + alphabet = true; + exDate = false; + toSortBy = 'lexigraphical'; + break; + case 3: + alphabet = true; + reverse = true; + exDate = false; + toSortBy = 'reverseLexigraphical'; + break; + case 4: + cat = true; + exDate = false; + toSortBy = 'category'; + break; + case 5: + cat = true; + reverse = true; + exDate = false; + toSortBy = 'categoryReversed'; + break; + default: + break; } - GridView ingreds = GridView.count( - shrinkWrap: true, - childAspectRatio: itemWidth / itemHeight, - crossAxisCount: 2, - padding: const EdgeInsets.all(15), - scrollDirection: Axis.vertical, - crossAxisSpacing: 20, - mainAxisSpacing: 15, - children: ingredients); - return SingleChildScrollView( - child: Container( - width: MediaQuery.of(context).size.width, - height: bodyHeight, - decoration: const BoxDecoration(color: Colors.white), - child: Column(children: [ - Container( - margin: const EdgeInsets.all(10), - width: MediaQuery.of(context).size.width, - decoration: const BoxDecoration( - color: Colors.transparent, - ), - child: Text(sorted, - style: const TextStyle( - fontSize: ingredientInfoFontSize, - color: Colors.black, - ))), - Expanded(child: ingreds) - ]))); - } + final res = + await Inventory.retrieveUserInventory(reverse, exDate, cat, alphabet); + bool success = false; + do { + if (res.statusCode == 200) { + var data = json.decode(res.body); + for (int i = 0; i < data.length; i += 2) { + for (var cats in data) { + List ingredients = []; + for (var ingred in cats[1]) { + ingredients + .add(IngredientData.create().inventoryIngredient(ingred)); + } + inventory[cats[i]] = ingredients; + } + } + success = true; + } else { + int errorCode = await getDataRetrieveError(res.statusCode); + if (errorCode == 3) { + errorDialog(context); + } + } + } while (!success); - // TODO(18): integrate API into making ingredients - Widget IngredientTile() { - double tileHeight = 200; + return inventory; + } - bool expiresSoon = false; - bool expired = true; + Future> BuildTiles() async { + List toRet = []; - Container expires = Container( - height: tileHeight, - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(20)), - color: expiresSoon - ? Colors.red[200] - : (expired ? const Color(0xffFF0000) : Colors.white), - ), - child: Align( - alignment: FractionalOffset.bottomLeft, - child: Padding( - padding: const EdgeInsets.all(10), - child: Row( - children: [ - Expanded( - child: Text( - expiresSoon ? 'Expires Soon!' : (expired ? 'Expired!' : ''), - style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), + for (var cat in userInventory.keys) { + if (userInventory[cat]!.length == 0) { + continue; + } + toRet.add(Text( + cat, + style: const TextStyle( + fontSize: addIngredientPageTextSize, + color: searchFieldText, ), - ), - ); + )); + toRet.add( + GridView.builder( + itemCount: itemsToDisplay < userInventory[cat]!.length + ? itemsToDisplay + : userInventory[cat]!.length, + shrinkWrap: true, + itemBuilder: (context, index) { + IngredientData item = userInventory[cat]![index]; + bool expiresSoon = false; + bool expired = false; - return SizedBox( - child: Stack( - children: [ - expires, - Container( - height: tileHeight - 40, - decoration: const BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(20)), - image: DecorationImage( - image: AssetImage('assets/images/Background.png'), - fit: BoxFit.fitWidth, - ), - ), - ), - Container( - height: tileHeight - 40, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: const BorderRadius.all(Radius.circular(20)), - gradient: LinearGradient( - begin: FractionalOffset.topCenter, - end: FractionalOffset.bottomCenter, - colors: [ - Colors.grey.withOpacity(0.0), - Colors.black.withOpacity(0.5), - ], - stops: [0.0, 0.75], - ), - ), - child: Align( - alignment: FractionalOffset.bottomLeft, - child: Padding( - padding: const EdgeInsets.all(10), - child: Row( - children: [ - Expanded( - child: Text( - 'To Be Changed', - style: TextStyle( - fontSize: ingredientInfoFontSize, - fontWeight: FontWeight.w600, - color: Colors.white, + double tileHeight = MediaQuery.of(context).size.height; + + if (item.expirationDate != 0) { + DateTime expDate = convertToDate(item.expirationDate); + + if (DateTime.now().difference(expDate).inDays < 7) { + expiresSoon = true; + } else { + if (expDate.difference(DateTime.now()).inDays > 0) { + expired = true; + } + } + } + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + Navigator.restorablePushNamed(context, '/food/food', arguments:IngredientArguments(ingredient: item, isEditing: false, navFromAdd: false)); + setState(() {}); + }, + child: Stack( + children: [ + Container( + height: tileHeight, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(20)), + color: expiresSoon + ? Colors.red[200] + : (expired ? const Color(0xffFF0000) : white), + ), + child: Align( + alignment: FractionalOffset.bottomLeft, + child: Padding( + padding: const EdgeInsets.all(10), + child: Row( + children: [ + Expanded( + child: Text( + expiresSoon + ? 'Expires Soon!' + : (expired ? 'Expired!' : ''), + style: const TextStyle( + color: white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ], ), - textAlign: TextAlign.left, ), ), - ], - ), + ), + Container( + height: tileHeight - 40, + decoration: const BoxDecoration( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20)), + ), + child: Image.network( + item.imageUrl, + fit: BoxFit.fitWidth, + ), + ), + Container( + height: tileHeight - 40, + decoration: BoxDecoration( + color: white, + borderRadius: const BorderRadius.all(Radius.circular(20)), + gradient: LinearGradient( + begin: FractionalOffset.topCenter, + end: FractionalOffset.bottomCenter, + colors: [ + Colors.grey.withOpacity(0.0), + black.withOpacity(0.5), + ], + stops: const [0.0, 0.75], + ), + ), + child: Align( + alignment: FractionalOffset.bottomLeft, + child: Padding( + padding: const EdgeInsets.all(10), + child: Row( + children: [ + Expanded( + child: Text( + item.name, + style: const TextStyle( + fontSize: ingredientInfoFontSize, + fontWeight: FontWeight.w600, + color: white, + ), + textAlign: TextAlign.left, + ), + ), + ], + ), + ), + ), + ), + ], ), - ), + ); + }, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 20, + mainAxisSpacing: 20, ), - ], - ), - ); + physics: const NeverScrollableScrollPhysics(), + ), + ); + } + itemsToDisplay += 30; + + return toRet; + } + + Future getDataRetrieveError(int statusCode) async { + switch (statusCode) { + case 400: + errorMessage = "Unknown error has occured"; + return 1; + case 401: + errorMessage = 'Reconnecting...'; + if (await tryTokenRefresh()) { + errorMessage = 'Reconnected'; + return 2; + } else { + errorMessage = 'Cannot connect to server'; + return 3; + } + case 404: + return 3; + case 503: + errorMessage = 'Cannot connect to server!'; + return 3; + default: + return 3; + } + } + + Future getDeleteError(int statusCode) async { + switch (statusCode) { + case 400: + errorMessage = "Unknown error has occured"; + return 1; + case 401: + errorMessage = 'Reconnecting...'; + if (await tryTokenRefresh()) { + errorMessage = 'Reconnected'; + return 2; + } else { + errorMessage = 'Cannot connect to server'; + return 3; + } + case 404: + return 3; + case 503: + errorMessage = 'Cannot connect to server!'; + return 3; + default: + return 3; + } } } class IngredientPage extends StatefulWidget { + IngredientArguments args; + + IngredientPage(this.args); + @override - _IngredientPageState createState() => _IngredientPageState(); + _IngredientPageState createState() => _IngredientPageState(args); } class _IngredientPageState extends State { + IngredientArguments args; + + _IngredientPageState(this.args); + + IngredientData ingredientToDisplay = IngredientData.create(); + bool isEditing = false; + bool navFromAddIngred = false; + @override void initState() { super.initState(); + ingredientToDisplay = args.ingredient; + isEditing = args.isEditing; + navFromAddIngred = args.navFromAdd; + getFullIngredientData(); + if (ingredientToDisplay.expirationDate != 0) { + _selectedDate = convertToDate(ingredientToDisplay.expirationDate); + } else { + _selectedDate = DateTime.now(); + } + _expirationDate.text = DateFormat.yMd().format(_selectedDate); } - List ingredientInfo = []; //fetchIngredients(int ID); + final _expirationDate = TextEditingController(); + bool unfilledExpirationDate = false; + late DateTime _selectedDate; + + String errorMessage = ''; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - backgroundColor: Colors.white, + backgroundColor: white, actions: [ - IconButton( - onPressed: () { - Navigator.restorablePushNamed(context, '/food/edit'); - }, - icon: const Icon(Icons.edit, color: Colors.black), - iconSize: topBarIconSize, - ), - IconButton( - onPressed: () {}, - icon: const Icon(Icons.delete, color: Colors.red), - iconSize: topBarIconSize, - ), + if (isEditing) + IconButton( + onPressed: () async { + if (navFromAddIngred) { + Map payload = { + 'id': ingredientToDisplay.ID, + 'name': ingredientToDisplay.name, + 'category': ingredientToDisplay.category, + 'image': {'srcUrl': ingredientToDisplay.imageUrl}, + 'expirationDate': _expirationDate.text.isEmpty + ? 0 + : convertToEpoch(_selectedDate) + }; + + final res = await Inventory.addIngredient(payload); + print(res.body); + if (res.statusCode == 201) { + errorMessage = 'Ingredient Added Successfully!'; + setState(() {}); + await messageDelay; + Navigator.pop(context); + navFromAddIngred = false; + } else { + int errorCode = await getError(res.statusCode); + if (errorCode == 2) { + final ret = await Inventory.addIngredient(payload); + if (ret.statusCode == 200) { + errorMessage = 'Ingredient Added Successfully!'; + await messageDelay; + Navigator.pop(context); + navFromAddIngred = false; + } else { + errorDialog(context); + } + } + } + } else { + Map payload = { + 'expirationDate': _expirationDate.text.isEmpty + ? 1 + : convertToEpoch(_selectedDate) + }; + final res = await Inventory.updateIngredientInInventory( + ingredientToDisplay.ID, payload); + if (res.statusCode == 200) { + errorMessage = 'Ingredient Updated Successfully!'; + await messageDelay; + Navigator.pop(context); + } else { + int errorCode = await getError(res.statusCode); + if (errorCode == 2) { + final res = await Inventory.updateIngredientInInventory( + ingredientToDisplay.ID, payload); + if (res.statusCode == 200) { + errorMessage = 'Ingredient Updated Successfully!'; + await messageDelay; + Navigator.pop(context); + navFromAddIngred = false; + } else { + errorDialog(context); + } + } + } + } + }, + icon: const Icon(Icons.check, color: Colors.red), + iconSize: topBarIconSize, + ), + if (!isEditing) + IconButton( + onPressed: () { + isEditing = true; + setState(() {}); + }, + icon: const Icon(Icons.edit, color: black), + iconSize: topBarIconSize, + ), + if (!isEditing) + IconButton( + onPressed: () async { + bool delete = false; + await showDialog( + context: context, + barrierDismissible: true, + builder: (context) { + return AlertDialog( + title: const Text( + 'Are you sure you want to remove this ingredient from your inventory?'), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + elevation: 15, + actions: [ + TextButton( + onPressed: () { + delete = false; + Navigator.pop(context); + }, + child: const Text( + 'Cancel', + style: TextStyle(color: Colors.red, fontSize: 18), + ), + ), + TextButton( + onPressed: () { + delete = true; + Navigator.pop(context); + }, + child: const Text( + 'Yes', + style: TextStyle(color: Colors.red, fontSize: 18), + ), + ) + ], + ); + }, + ); + + if (!delete) { + return; + } + + final res = await Inventory.deleteIngredientfromInventory( + ingredientToDisplay.ID); + if (res.statusCode == 200) { + errorMessage = + 'Successfully deleted ingredient from inventory!'; + await messageDelay; + Navigator.pop(context); + } else { + int errorCode = await getError(res.statusCode); + if (errorCode == 2) { + final ret = await Inventory.deleteIngredientfromInventory( + ingredientToDisplay.ID); + if (ret.statusCode == 200) { + errorMessage = + 'Successfully deleted ingredient from inventory!'; + await messageDelay; + Navigator.pop(context); + } else { + errorDialog(context); + } + } + } + }, + icon: const Icon(Icons.delete, color: Colors.red), + iconSize: topBarIconSize, + ), ], leading: IconButton( - icon: const Icon(Icons.navigate_before, color: Colors.black), + icon: const Icon(Icons.navigate_before, color: black), iconSize: 35, onPressed: () { - Navigator.pop(context); + if (isEditing) { + isEditing = false; + setState(() {}); + } else { + Navigator.pop(context); + } }, )), body: Container( @@ -519,222 +981,157 @@ class _IngredientPageState extends State { height: MediaQuery.of(context).size.height, margin: const EdgeInsets.fromLTRB(5, 10, 5, 0), child: SingleChildScrollView( - child: Column( - children: [ - Container( - width: MediaQuery.of(context).size.width / 2, - height: MediaQuery.of(context).size.width / 2, - margin: const EdgeInsets.symmetric(vertical: 20), - decoration: BoxDecoration( - border: Border.all(color: Colors.black, width: 3), - color: Colors.grey, - ), - child: const Text('Ingredient image'), - ), - Container( - width: MediaQuery.of(context).size.width, - margin: const EdgeInsets.symmetric(vertical: 20), - child: const Text( - 'Food Item', - style: TextStyle( - fontSize: 36, - color: Colors.black, - ), - textAlign: TextAlign.center, - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, + child: FutureBuilder( + future: fetchIngredientData(), + builder: (BuildContext context, AsyncSnapshot snapshot) { + switch (snapshot.connectionState) { + case ConnectionState.waiting: + break; + case ConnectionState.done: + break; + } + return Column( children: [ - Column( - children: [ - const Text( - 'Quantity', - style: TextStyle( - fontSize: ingredientInfoFontSize, - color: Colors.black, - ), - textAlign: TextAlign.center, - ), - Container( - width: MediaQuery.of(context).size.width / 2.2, - margin: const EdgeInsets.only(right: 10), - padding: const EdgeInsets.symmetric( - vertical: 10, horizontal: 5), - child: Text( - '{amount}', - style: TextStyle( - fontSize: ingredientInfoFontSize, - color: Colors.black, - ), - textAlign: TextAlign.left, - ), + Container( + width: MediaQuery.of(context).size.width / 2, + height: MediaQuery.of(context).size.width / 2, + margin: const EdgeInsets.symmetric(vertical: 20), + child: Image.network( + ingredientToDisplay.imageUrl, + fit: BoxFit.contain, + ), + ), + Container( + width: MediaQuery.of(context).size.width, + margin: const EdgeInsets.symmetric(vertical: 20), + child: Text( + ingredientToDisplay.name, + style: const TextStyle( + fontSize: 36, + color: black, ), - ], + textAlign: TextAlign.center, + ), ), - Column( + Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ - const Text( - 'Food Group', - style: TextStyle( - fontSize: ingredientInfoFontSize, - color: Colors.black, - ), - textAlign: TextAlign.center, - ), - Container( - width: MediaQuery.of(context).size.width / 2.2, - margin: const EdgeInsets.only(right: 10), - padding: const EdgeInsets.symmetric( - vertical: 10, horizontal: 5), - child: Text( - '{group}', - style: TextStyle( - fontSize: ingredientInfoFontSize, - color: Colors.black, + Column( + children: [ + const Text( + 'Food Categories', + style: TextStyle( + fontSize: ingredientInfoFontSize, + color: black, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.left, - ), + Container( + padding: const EdgeInsets.symmetric( + vertical: 10, horizontal: 5), + child: Text( + ingredientToDisplay.category.isEmpty + ? 'Miscellaneous' + : ingredientToDisplay.category, + style: ingredientInfoTextStyle, + textAlign: TextAlign.center, + ), + ), + ], ), ], ), - ], - ), - const SizedBox( - height: 20, - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Column( + Text( + errorMessage, + style: errorTextStyle, + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ - const Text( - 'Location', - style: TextStyle( - fontSize: ingredientInfoFontSize, - color: Colors.black, - ), - textAlign: TextAlign.center, - ), - Container( - width: MediaQuery.of(context).size.width / 2.2, - margin: const EdgeInsets.only(right: 10), - padding: const EdgeInsets.symmetric( - vertical: 10, horizontal: 5), - child: Text( - '{maybe}', - style: TextStyle( - fontSize: ingredientInfoFontSize, - color: Colors.black, + Column( + children: [ + Text( + 'Expiration Date(s)', + style: ingredientInfoTextStyle, + textAlign: TextAlign.center, ), - textAlign: TextAlign.left, - ), + Container( + width: MediaQuery.of(context).size.width / 2, + margin: const EdgeInsets.symmetric(horizontal: 5), + padding: const EdgeInsets.symmetric( + vertical: 5, horizontal: 5), + decoration: const BoxDecoration( + color: mainScheme, + borderRadius: + BorderRadius.all(Radius.circular(10)), + ), + child: isEditing + ? TextField( + focusNode: AlwaysDisabledFocusNode(), + controller: _expirationDate, + decoration: unfilledExpirationDate + ? invalidTextField.copyWith( + hintText: 'Click to choose a date') + : globalDecoration.copyWith( + hintText: 'Click to choose a date'), + onTap: () { + if (isEditing) _selectDate(context); + }) + : Text( + _expirationDate.value.text, + style: ingredientInfoTextStyle, + textAlign: TextAlign.center, + ), + ), + ], ), ], ), - Column( - children: [ - const Text( - 'Expiration Date(s)', - style: TextStyle( - fontSize: ingredientInfoFontSize, - color: Colors.black, - ), - textAlign: TextAlign.center, - ), - Container( - width: MediaQuery.of(context).size.width / 2.2, - margin: const EdgeInsets.only(right: 10), - padding: const EdgeInsets.symmetric( - vertical: 10, horizontal: 5), - child: Text( - '{dates}', - style: TextStyle( - fontSize: ingredientInfoFontSize, - color: Colors.black, + Container( + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height, + padding: const EdgeInsets.fromLTRB(5, 20, 5, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: MediaQuery.of(context).size.width, + child: Text( + 'Nutrition Values:', + style: ingredientInfoTextStyle, + textAlign: TextAlign.left, ), - textAlign: TextAlign.left, ), - ), - ], + Expanded( + child: BuildNutrientTiles(), + ), + ], + ), ), ], - ), - Container( - width: MediaQuery.of(context).size.width, - padding: const EdgeInsets.fromLTRB(5, 20, 5, 0), + ); + }, + ), + ), + ), + extendBody: false, + extendBodyBehindAppBar: false, + bottomNavigationBar: BottomAppBar( + child: Container( + height: bottomRowHeight, + width: MediaQuery.of(context).size.width, + decoration: BoxDecoration( + border: + Border(top: BorderSide(color: black.withOpacity(.2), width: 3)), + color: white, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + flex: 2, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: MediaQuery.of(context).size.width, - child: Text( - 'Nutrition Values: {amount}', - style: TextStyle( - fontSize: ingredientInfoFontSize, - color: Colors.black, - ), - textAlign: TextAlign.left, - ), - ), - SizedBox( - width: MediaQuery.of(context).size.width, - child: Text( - '\nBrands: {group}', - style: TextStyle( - fontSize: ingredientInfoFontSize, - color: Colors.black, - ), - textAlign: TextAlign.left, - ), - ), - SizedBox( - width: MediaQuery.of(context).size.width, - child: Text( - '\nTags: {maybe}', - style: TextStyle( - fontSize: ingredientInfoFontSize, - color: Colors.black, - ), - textAlign: TextAlign.left, - ), - ), - SizedBox( - width: MediaQuery.of(context).size.width, - child: Text( - '\nAllergens: {dates}', - style: TextStyle( - fontSize: ingredientInfoFontSize, - color: Colors.black, - ), - textAlign: TextAlign.left, - ), - ), - ], - ), - ), - ], - ), - ), - ), - extendBody: false, - extendBodyBehindAppBar: false, - bottomNavigationBar: BottomAppBar( - child: Container( - height: bottomRowHeight, - width: MediaQuery.of(context).size.width, - decoration: BoxDecoration( - border: Border( - top: BorderSide(color: Colors.black.withOpacity(.2), width: 3)), - color: Colors.white, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - flex: 2, - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, children: [ IconButton( onPressed: () {}, @@ -823,337 +1220,425 @@ class _IngredientPageState extends State { ); } - // TODO(17): Attach API to get specified ingredient information - // - // List fetchIngredient(int ID) { - // ingredientInfo = a json of some sort; - // } - // + void getFullIngredientData() async { + ingredientToDisplay = await fetchIngredientData(); + setState(() {}); + } + + _selectDate(BuildContext context) async { + DateTime? newSelectedDate = await showDatePicker( + context: context, + initialDate: _selectedDate != null ? _selectedDate : DateTime.now(), + firstDate: DateTime(2020), + lastDate: DateTime(2030), + builder: (BuildContext context, Widget? child) { + return Theme( + data: ThemeData.dark().copyWith( + colorScheme: const ColorScheme.dark( + primary: mainScheme, + onPrimary: black, + surface: Colors.grey, + onSurface: black, + ), + dialogBackgroundColor: white, + ), + child: child as Widget); + }); + + if (newSelectedDate != null) { + _selectedDate = newSelectedDate; + _expirationDate.text = DateFormat.yMd().format(_selectedDate); + } + setState(() {}); + } + + DateTime convertToDate(int secondEpoch) { + var date = DateTime.fromMicrosecondsSinceEpoch(secondEpoch * 1000); + return date; + } + + int convertToEpoch(DateTime date) { + return date.toUtc().microsecondsSinceEpoch; + } + + Widget BuildNutrientTiles() { + if (ingredientToDisplay.nutrients.length == 0) { + return Container(); + } + + List nuts = ingredientToDisplay.nutrients; + + ListView toRet = ListView.builder( + padding: const EdgeInsets.all(10), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: nuts.length, + itemBuilder: (BuildContext context, int index) { + if (index == 0) { + return Row( + children: [ + Expanded( + flex: 4, + child: Text( + 'Nutrient Name', + style: ingredientInfoTextStyle, + textAlign: TextAlign.left, + ), + ), + Expanded( + flex: 3, + child: Text( + 'Amount', + style: ingredientInfoTextStyle, + textAlign: TextAlign.right, + ), + ), + Expanded( + flex: 3, + child: Text( + '% value', + style: ingredientInfoTextStyle, + textAlign: TextAlign.right, + ), + ), + ], + ); + } else { + if (nuts[index].unit.value == 0) return Container(); + return Row( + children: [ + Expanded( + flex: 4, + child: Text( + nuts[index].name, + style: ingredientInfoTextStyle, + textAlign: TextAlign.left, + ), + ), + Expanded( + flex: 3, + child: Text( + '${nuts[index].unit.value} ${nuts[index].unit.unit}', + style: ingredientInfoTextStyle, + textAlign: TextAlign.right, + ), + ), + Expanded( + flex: 3, + child: Text( + '${nuts[index].percentOfDaily}%', + style: ingredientInfoTextStyle, + textAlign: TextAlign.right, + ), + ), + ], + ); + } + }, + ); + + return toRet; + } + + Future getError(int status) async { + switch (status) { + case 400: + errorMessage = "Incorrect Request Format"; + return 1; + case 401: + errorMessage = 'Reconnecting...'; + if (await tryTokenRefresh()) { + errorMessage = 'Reconnected!'; + return 2; + } else { + errorMessage = 'Cannot connect to server'; + return 3; + } + case 404: + errorMessage = 'User not found'; + return 3; + default: + return 3; + } + } + + Future fetchIngredientData() async { + IngredientData newIngred = ingredientToDisplay; + final res = await Ingredients.getIngredientByID(newIngred.ID, 0, ''); + if (res.statusCode == 200) { + var data = json.decode(res.body); + newIngred.addInformationToIngredient(data); + } + return newIngred; + } } -class EditIngredientPage extends StatefulWidget { +class AddIngredientPage extends StatefulWidget { @override - _EditIngredientPageState createState() => _EditIngredientPageState(); + _AddIngredientPageState createState() => _AddIngredientPageState(); } -class _EditIngredientPageState extends State { +class _AddIngredientPageState extends State { + late ScrollController loading; + final searchController = TextEditingController(); + List searchResultList = []; + late ListView resultsList; + late FocusNode _search; + @override void initState() { + loading = ScrollController()..addListener(_scrollListener); + _search = FocusNode(); + _search.addListener(_onFocusChange); + resultsList = ListView(key: key); super.initState(); } - final _quantity = TextEditingController(); - bool unfilledQuantity = false; - - final _location = TextEditingController(); - bool unfilledLocation = false; + @override + void dispose() { + loading.removeListener(_scrollListener); + super.dispose(); + } - final _expirationDate = TextEditingController(); - bool unfilledExpirationDate = false; + UniqueKey key = UniqueKey(); - final _brands = TextEditingController(); - bool unfilledBrands = false; + bool searching = false; + bool searchChanged = false; + String oldQuery = ''; + String errorMessage = ''; + int pageCount = 1; + int queryID = -1; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - backgroundColor: Colors.white, - actions: [ - IconButton( - onPressed: () { - Navigator.pop(context); - }, - icon: const Icon(Icons.check, color: Colors.red), - iconSize: topBarIconSize, - ), - ], + backgroundColor: white, leading: IconButton( - icon: const Icon(Icons.navigate_before, color: Colors.black), - iconSize: topBarIconSize + 7, + icon: const Icon(Icons.navigate_before, color: black), + iconSize: 35, onPressed: () { Navigator.pop(context); }, )), - body: GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () => FocusManager.instance.primaryFocus?.unfocus(), + body: SingleChildScrollView( child: Container( width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height - bottomRowHeight, margin: const EdgeInsets.fromLTRB(5, 10, 5, 0), - child: SingleChildScrollView( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + FocusManager.instance.primaryFocus?.unfocus(); + }, child: Column( children: [ Container( - width: MediaQuery.of(context).size.width / 2, - height: MediaQuery.of(context).size.width / 2, - margin: const EdgeInsets.symmetric(vertical: 20), - decoration: BoxDecoration( - border: Border.all(color: Colors.black, width: 3), - color: Colors.grey, + width: MediaQuery.of(context).size.width, + margin: const EdgeInsets.only( + top: 50, bottom: 20, left: 15, right: 15), + child: Row( + children: const [ + Flexible( + child: Text( + 'Search for an ingredient to get started', + style: TextStyle( + fontSize: addIngredientPageTextSize, + color: black, + fontWeight: FontWeight.w400, + ), + textAlign: TextAlign.center, + ), + ), + ], ), - child: const Text('Ingredient image'), + ), + Text( + errorMessage, + style: + errorTextStyle.copyWith(fontSize: ingredientInfoFontSize), + textAlign: TextAlign.center, ), Container( - width: MediaQuery.of(context).size.width, - margin: const EdgeInsets.symmetric(vertical: 20), - child: const Text( - 'Food Item', - style: TextStyle( - fontSize: 36, - color: Colors.black, - ), - textAlign: TextAlign.center, + width: MediaQuery.of(context).size.width / 1.5, + padding: const EdgeInsets.all(5), + decoration: const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(20)), + color: textFieldBacking, ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - flex: 5, - child: Column( - children: [ - const Text( - 'Quantity', - style: TextStyle( - fontSize: ingredientInfoFontSize, - color: Colors.black, - ), - textAlign: TextAlign.center, - ), - Container( - margin: const EdgeInsets.symmetric(horizontal: 10), - padding: const EdgeInsets.symmetric( - vertical: 5, horizontal: 5), - decoration: const BoxDecoration( - color: mainScheme, - borderRadius: - BorderRadius.all(Radius.circular(10)), - ), - child: TextField( - maxLines: 1, - controller: _quantity, - decoration: unfilledQuantity - ? invalidTextField.copyWith( - hintText: 'Enter Quantity') - : globalDecoration.copyWith( - hintText: 'Enter Quantity'), - style: const TextStyle( + child: LayoutBuilder(builder: + (BuildContext context, BoxConstraints constraints) { + return Row( + children: [ + SizedBox( + width: constraints.maxWidth - topBarIconSize, + child: TextField( + key: key, + maxLines: 1, + focusNode: _search, + controller: searchController, + decoration: const InputDecoration.collapsed( + hintText: 'Search...', + hintStyle: TextStyle( + color: searchFieldText, fontSize: ingredientInfoFontSize, - color: Colors.black, ), - onChanged: (quantity) { - if (quantity.isEmpty) { - setState(() => unfilledQuantity = true); - } else { - setState(() => unfilledQuantity = false); - } - }, - textAlign: TextAlign.left, - textInputAction: TextInputAction.next, ), - ), - ], - ), - ), - Expanded( - flex: 5, - child: Column( - children: [ - const Text( - 'Food Group', - style: TextStyle( + style: const TextStyle( + color: searchFieldText, fontSize: ingredientInfoFontSize, - color: Colors.black, ), - textAlign: TextAlign.center, + textInputAction: TextInputAction.done, + onChanged: (query) { + if (query == oldQuery) { + searchChanged = false; + } else { + searchChanged = true; + } + }, + onSubmitted: (query) async { + if (query.isNotEmpty && searchChanged) { + pageCount = 1; + oldQuery = query; + setList(); + } + if (query.isEmpty) { + oldQuery = ''; + resultsList = ListView( + controller: loading, + ); + } + searchChanged = false; + }, ), - Container( - width: MediaQuery.of(context).size.width, - margin: const EdgeInsets.symmetric(horizontal: 5), - padding: const EdgeInsets.symmetric( - vertical: 10, horizontal: 5), - child: Text( - '{group}', - style: TextStyle( - fontSize: ingredientInfoFontSize, - color: Colors.black, - ), - textAlign: TextAlign.left, - ), - ), - ], - ), - ), - ], - ), - const SizedBox( - height: 20, + ), + const Icon( + Icons.search, + color: black, + size: topBarIconSize, + ), + ], + ); + }), ), - Row( - mainAxisAlignment: MainAxisAlignment.center, + Stack( + alignment: Alignment.center, children: [ - Expanded( - flex: 5, - child: Column( - children: [ - const Text( - 'Location', - style: TextStyle( - fontSize: ingredientInfoFontSize, - color: Colors.black, - ), - textAlign: TextAlign.center, + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: MediaQuery.of(context).size.width, + padding: const EdgeInsets.all(10), + child: Row( + children: const [ + Flexible( + child: Text( + 'Click on the ingredient name to continue', + style: TextStyle( + fontSize: 24, + color: black, + fontWeight: FontWeight.w400, + ), + textAlign: TextAlign.center, + ), + ), + ], ), - Container( - margin: const EdgeInsets.symmetric(horizontal: 5), - padding: const EdgeInsets.symmetric( - vertical: 5, horizontal: 5), - decoration: const BoxDecoration( - color: mainScheme, - borderRadius: - BorderRadius.all(Radius.circular(10)), - ), - child: TextField( - maxLines: 1, - controller: _location, - decoration: globalDecoration.copyWith( - hintText: 'Enter Location'), - style: const TextStyle( - fontSize: ingredientInfoFontSize, - color: Colors.black, + ), + Container( + width: MediaQuery.of(context).size.width, + padding: const EdgeInsets.only( + top: 100, left: 15, right: 15, bottom: 50), + child: Row( + children: const [ + Flexible( + child: Text( + 'Or scan a barcode to automatically add it to your inventory', + style: TextStyle( + fontSize: addIngredientPageTextSize, + color: black, + fontWeight: FontWeight.w400, + ), + textAlign: TextAlign.center, + ), ), - onChanged: (location) {}, - textAlign: TextAlign.left, - textInputAction: TextInputAction.next, + ], + ), + ), + ElevatedButton( + onPressed: () { + // TODO(31): Add ability to scan barcodes + }, + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), ), + backgroundColor: mainScheme, + padding: const EdgeInsets.symmetric( + vertical: 15, horizontal: 25), + shadowColor: black, ), - ], - ), - ), - Expanded( - flex: 5, - child: Column( - children: [ - const Text( - 'Expiration Date(s)', + child: const Text( + 'Scan!', style: TextStyle( - fontSize: ingredientInfoFontSize, - color: Colors.black, + fontSize: 50, + color: white, + fontWeight: FontWeight.w400, ), textAlign: TextAlign.center, ), - Container( - margin: const EdgeInsets.symmetric(horizontal: 5), - padding: const EdgeInsets.symmetric( - vertical: 5, horizontal: 5), - decoration: const BoxDecoration( - color: mainScheme, - borderRadius: - BorderRadius.all(Radius.circular(10)), - ), - child: TextField( - maxLines: 1, - controller: _expirationDate, - decoration: unfilledExpirationDate - ? invalidTextField.copyWith( - hintText: 'Enter Expiration Date') - : globalDecoration.copyWith( - hintText: 'Enter Expiration Date'), - style: const TextStyle( - fontSize: ingredientInfoFontSize, - color: Colors.black, - ), - onChanged: (location) {}, - textAlign: TextAlign.left, - textInputAction: TextInputAction.next, - ), - ), - ], - ), - ), - ], - ), - Container( - width: MediaQuery.of(context).size.width, - padding: const EdgeInsets.fromLTRB(5, 20, 5, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: MediaQuery.of(context).size.width, - child: Text( - 'Nutrition Values: {amount}', - style: TextStyle( - fontSize: ingredientInfoFontSize, - color: Colors.black, - ), - textAlign: TextAlign.left, ), - ), - Row(children: [ - Text( - '\nBrands: ', - style: TextStyle( - fontSize: ingredientInfoFontSize, - color: Colors.black, - ), - textAlign: TextAlign.left, - ), - Expanded( - child: Container( - margin: const EdgeInsets.only(right: 10), - padding: const EdgeInsets.symmetric( - vertical: 5, horizontal: 5), - decoration: const BoxDecoration( - color: mainScheme, - borderRadius: - BorderRadius.all(Radius.circular(10)), + ], + ), + searching + ? Container( + width: MediaQuery.of(context).size.width / 1.5, + height: MediaQuery.of(context).size.height / 2, + decoration: BoxDecoration( + color: white, + border: Border.all(color: searchFieldText), ), - child: TextField( - maxLines: 1, - controller: _brands, - decoration: unfilledBrands - ? invalidTextField.copyWith( - hintText: 'Enter Brands') - : globalDecoration.copyWith( - hintText: 'Enter Brands'), - style: const TextStyle( - fontSize: 14, - color: Colors.black, - ), - onChanged: (location) {}, - textAlign: TextAlign.left, - textInputAction: TextInputAction.next, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: FutureBuilder( + builder: (BuildContext context, + AsyncSnapshot snapshot) { + switch (snapshot.connectionState) { + case ConnectionState.waiting: + return SizedBox( + width: MediaQuery.of(context) + .size + .width / + 1.5, + height: MediaQuery.of(context) + .size + .height, + child: const Text( + 'Loading...', + style: TextStyle( + fontSize: + ingredientInfoFontSize, + color: searchFieldText, + ), + textAlign: TextAlign.center, + ), + ); + case ConnectionState.done: + return resultsList; + default: + return resultsList; + } + }, + ), + ), + ], ), - ), - ), - ]), - SizedBox( - width: MediaQuery.of(context).size.width, - child: Text( - '\nTags: {maybe}', - style: TextStyle( - fontSize: ingredientInfoFontSize, - color: Colors.black, - ), - textAlign: TextAlign.left, - ), - ), - SizedBox( - width: MediaQuery.of(context).size.width, - child: Text( - '\nAllergens: {dates}', - style: TextStyle( - fontSize: ingredientInfoFontSize, - color: Colors.black, - ), - textAlign: TextAlign.left, - ), - ), - ], - ), + ) + : Container(), + ], ), ], ), @@ -1167,9 +1652,9 @@ class _EditIngredientPageState extends State { height: bottomRowHeight, width: MediaQuery.of(context).size.width, decoration: BoxDecoration( - border: Border( - top: BorderSide(color: Colors.black.withOpacity(.2), width: 3)), - color: Colors.white, + border: + Border(top: BorderSide(color: black.withOpacity(.2), width: 3)), + color: white, ), child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -1266,244 +1751,129 @@ class _EditIngredientPageState extends State { ); } -// TODO(17): Attach API to get specified ingredient information -// -// List fetchIngredient(int ID) { -// ingredientInfo = a json of some sort; -// } -// -} + void _scrollListener() { + if (loading.position.atEdge) { + bool isTop = loading.position.pixels == 0; + if (!isTop) { + setList(); + } + } + } -class AddIngredientPage extends StatefulWidget { - @override - _AddIngredientPageState createState() => _AddIngredientPageState(); -} + void _onFocusChange() { + if (_search.hasFocus) { + searching = true; + } + if (!_search.hasFocus && searchController.value.text.isEmpty) { + searching = false; + } + } -class _AddIngredientPageState extends State { - @override - void initState() { - super.initState(); + void setList() async { + resultsList = await buildSearchList(searchController.text); + setState(() {}); } - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - backgroundColor: Colors.white, - leading: IconButton( - icon: const Icon(Icons.navigate_before, color: Colors.black), - iconSize: 35, - onPressed: () { - Navigator.pop(context); - }, - )), - body:Container( - width: MediaQuery.of(context).size.width, - height: MediaQuery.of(context).size.height, - margin: const EdgeInsets.fromLTRB(5, 10, 5, 0), - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () { - FocusManager.instance.primaryFocus?.unfocus(); - }, - child: Column( - children: [ - Container( - width: MediaQuery.of(context).size.width, - margin: - const EdgeInsets.symmetric(vertical: 50, horizontal: 15), - child: Row( - children: const [ - Flexible( - child: Text( - 'Search for an ingredient to get started', - style: TextStyle( - fontSize: addIngredientPageTextSize, - color: Colors.black, - fontWeight: FontWeight.w400, - ), - textAlign: TextAlign.center, - ), - ), - ], - ), - ), - Container( - width: MediaQuery.of(context).size.width / 1.5, - padding: const EdgeInsets.all(5), - decoration: const BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(20)), - color: textFieldBacking, - ), - child: LayoutBuilder(builder: - (BuildContext context, BoxConstraints constraints) { - return Row( - children: [ - SizedBox( - width: constraints.maxWidth - topBarIconSize, - child: searchField, - ), - const Icon( - Icons.search, - color: Colors.black, - size: topBarIconSize, - ), - ], - ); - }), - ), - Container( - width: MediaQuery.of(context).size.width, - padding: const EdgeInsets.only( - top: 150, left: 15, right: 15, bottom: 50), - child: Row( - children: const [ - Flexible( - child: Text( - 'Or scan a barcode to automatically add it to your inventory', - style: TextStyle( - fontSize: addIngredientPageTextSize, - color: Colors.black, - fontWeight: FontWeight.w400, - ), - textAlign: TextAlign.center, - ), - ), - ], - ), - ), - ElevatedButton( - onPressed: () { - // TODO(31): Add ability to scan barcodes - }, - style: ElevatedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - backgroundColor: mainScheme, - padding: - const EdgeInsets.symmetric(vertical: 15, horizontal: 25), - shadowColor: Colors.black, - ), - child: const Text( - 'Scan!', - style: TextStyle( - fontSize: 50, - color: Colors.white, - fontWeight: FontWeight.w400, - ), - textAlign: TextAlign.center, + Future buildSearchList(String searchQuery) async { + bool updated = await updateSearchList(searchQuery); + + if (searchResultList.length == 0) { + return ListView( + children: [ + Align( + alignment: Alignment.center, + child: Container( + width: MediaQuery.of(context).size.width / 1.5, + color: white, + padding: const EdgeInsets.all(10), + child: const Text( + 'Sorry, your query produced no results', + style: TextStyle( + color: searchFieldText, + fontSize: ingredientInfoFontSize, ), + textAlign: TextAlign.center, ), - ], - ), - ), - ), - extendBody: false, - extendBodyBehindAppBar: false, - bottomNavigationBar: BottomAppBar( - child: Container( - height: bottomRowHeight, - width: MediaQuery.of(context).size.width, - decoration: BoxDecoration( - border: Border( - top: BorderSide(color: Colors.black.withOpacity(.2), width: 3)), - color: Colors.white, + ), ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - flex: 2, - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - IconButton( - onPressed: () {}, - icon: const Icon(Icons.egg), - iconSize: bottomIconSize, - color: mainScheme, - ), - Text( - 'Ingredients', - style: bottomRowOnScreenTextStyle, - textAlign: TextAlign.center, - ) - ], - ), - ), - Expanded( - flex: 2, - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - IconButton( - onPressed: () { - Navigator.restorablePushReplacementNamed( - context, '/recipe'); - }, - icon: const Icon(Icons.restaurant), - iconSize: bottomIconSize, - color: bottomRowIcon, - ), - Text( - 'Recipes', - style: bottomRowIconTextStyle, - textAlign: TextAlign.center, - ) - ], - ), - ), - Expanded( - flex: 2, - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - IconButton( - onPressed: () { - Navigator.restorablePushReplacementNamed( - context, '/cart'); - }, - icon: const Icon(Icons.shopping_cart), - iconSize: bottomIconSize, - color: bottomRowIcon, - ), - Text( - 'Shopping Cart', - style: bottomRowIconTextStyle, - textAlign: TextAlign.center, - ) - ], - ), - ), - Expanded( - flex: 2, - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - IconButton( - onPressed: () { - Navigator.restorablePushReplacementNamed( - context, '/user'); - }, - icon: const Icon(Icons.person), - iconSize: bottomIconSize, - color: bottomRowIcon, - ), - Text( - 'User Profile', - style: bottomRowIconTextStyle, - textAlign: TextAlign.center, - ) - ], - ), + ], + ); + } + + return ListView.builder( + padding: const EdgeInsets.all(10), + itemCount: searchResultList.length, + controller: loading, + itemBuilder: (BuildContext context, int index) { + return ListTile( + key: key, + title: Text( + index == searchResultList.length && !updated + ? 'No items to list' + : searchResultList[index].name, + style: const TextStyle( + color: searchFieldText, + fontSize: 18, ), - ], - ), - ), - ), + textAlign: TextAlign.left, + ), + trailing: const Icon( + Icons.call_made, + color: black, + size: searchIconButtonSize, + ), + onTap: () async { + errorMessage = ''; + final res = await Ingredients.getIngredientByID( + searchResultList[index].ID, 0, ''); + if (res.statusCode == 200) { + var data = json.decode(res.body); + IngredientData toPass = searchResultList[index]; + toPass.completeIngredient(data); + Navigator.popAndPushNamed(context, '/food/food', arguments: IngredientArguments(ingredient: toPass, isEditing: true, navFromAdd: true)); + } else { + errorMessage = 'Could not retrieve item details!'; + } + setState(() => searching = false); + }); + }, ); } - // TODO(26): Allow searching of ingredients to add predefined things + Future updateSearchList(String searchQuery) async { + int resultsPerPage = 20; + int oldLength = searchResultList.length; + if (pageCount == 1) { + searchResultList = []; + } + + if (searchQuery.isEmpty) { + searchResultList = []; + return false; + } + + final res = await Ingredients.searchIngredients( + searchQuery, resultsPerPage, pageCount++, ''); + if (res.statusCode != 200) { + return false; + } + + var data = json.decode(res.body); + for (var value in data['results']) { + searchResultList.add(IngredientData.create().baseIngredient(value)); + } + + if (searchResultList.length == oldLength) { + return false; + } + + return true; + } +} + +class IngredientArguments { + IngredientData ingredient; + bool isEditing; + bool navFromAdd; + IngredientArguments({required this.ingredient, required this.isEditing, required this.navFromAdd}); } diff --git a/lib/screens/StartupScreen.dart b/lib/screens/StartupScreen.dart index 35a86b6..50081c7 100644 --- a/lib/screens/StartupScreen.dart +++ b/lib/screens/StartupScreen.dart @@ -1,8 +1,11 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; +import 'package:image_picker/image_picker.dart'; +import 'package:smart_chef/screens/LoadingOverlay.dart'; import 'package:smart_chef/utils/APIutils.dart'; import 'package:smart_chef/utils/authAPI.dart'; import 'package:smart_chef/utils/colors.dart'; @@ -173,14 +176,14 @@ class _LogInPageState extends State { } //TODO(30): Reset Password Functionality - //int state = 0; - // Widget detectState() { - // if (state == 1) { - // return buildForgot(); - // } else { - // return buildLogIn(); - // } - // } + int state = 0; + Widget detectState() { + if (state == 1) { + return buildForgot(); + } else { + return buildLogIn(); + } + } final _username = TextEditingController(); bool unfilledUsername = false; @@ -203,12 +206,9 @@ class _LogInPageState extends State { child: Scaffold( backgroundColor: Colors.black.withOpacity(.35), body: GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () => FocusManager.instance.primaryFocus?.unfocus(), - child: SingleChildScrollView( - child: SizedBox( - width: MediaQuery.of(context).size.width, - height: MediaQuery.of(context).size.height, + behavior: HitTestBehavior.translucent, + onTap: () => FocusManager.instance.primaryFocus?.unfocus(), + child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -234,14 +234,12 @@ class _LogInPageState extends State { ), ), ), - //detectState(), - buildLogIn() + detectState(), ], ), ), ), ), - ), ); } @@ -370,6 +368,19 @@ class _LogInPageState extends State { setState(() => unfilledPassword = false); } }, + onSubmitted: (sub) async { + bool logged = await runLogin(); + if (logged) { + setState(() => clearFields()); + Navigator.restorablePushNamedAndRemoveUntil( + context, '/food', ((Route route) => false)); + } else { + setState(() { + unfilledUsername = true; + unfilledPassword = true; + }); + } + }, textInputAction: TextInputAction.done, ), ), @@ -392,83 +403,17 @@ class _LogInPageState extends State { children: [ ElevatedButton( onPressed: () async { - if (allLoginFieldsValid(/*hasPassword=*/ true)) { - Map payload = { - 'username': _username.value.text.trim(), - 'password': _password.value.text.trim() - }; - - try { - final ret = await Authentication.login(payload); - if (ret.statusCode == 200) { - var tokens = json.decode(ret.body); - user.defineTokens(tokens); - - final res = await User.getUser(); - if (res.statusCode == 200) { - var data = json.decode(res.body); - user.defineUserData(data); - user.setPassword( - _password.value.text.trim()); - - setState(() => clearFields()); - Navigator.restorablePushNamedAndRemoveUntil( - context, - '/food', - ((Route route) => false)); - } else { - errorMessage = - getDataRetrieveError(res.statusCode); - } - } else { - errorMessage = getLogInError(ret.statusCode); - if (ret.statusCode == 403) { - user.username = _username.value.text.trim(); - showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: const Text( - 'Account not verified'), - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(10)), - elevation: 15, - actions: [ - TextButton( - onPressed: () { - user.username = - _username.value.text; - Navigator - .restorablePushReplacementNamed( - context, - '/verification'); - }, - child: const Text( - 'OK', - style: TextStyle( - color: Colors.red, - fontSize: 18), - ), - ), - ], - content: Column( - mainAxisSize: MainAxisSize.min, - children: const [ - Flexible( - child: Text( - 'Your account is not verified!\nPress OK to be taken to the verification page')), - ]), - ); - }); - } - } - } catch (e) { - errorMessage = 'Could not connect to server'; - print('Could not connect to /auth/user'); - } + bool logged = await runLogin(); + if (logged) { + setState(() => clearFields()); + Navigator.restorablePushNamedAndRemoveUntil( + context, '/food', ((Route route) => false)); + } else { + setState(() { + unfilledUsername = true; + unfilledPassword = true; + }); } - setState(() {}); }, style: buttonStyle, child: const Text( @@ -495,12 +440,10 @@ class _LogInPageState extends State { ), ), onPressed: () { - // TODO(30): Resetting Password - // clearFields(); - // topMessage = 'Forgot Your\nPassword?'; - // setState(() { - // state = 1; - // }); + clearFields(); + topMessage = 'Forgot Your\nPassword?'; + errorMessage = ''; + setState(() => state = 1); }, child: const Text('Forgot Your Password?'), ), @@ -514,13 +457,13 @@ class _LogInPageState extends State { ); } - bool allLoginFieldsValid(bool hasPassword) { + bool allLoginFieldsValid() { bool toReturn = true; if (_username.value.text.isEmpty) { toReturn = false; setState(() => unfilledUsername = true); } - if (hasPassword & _password.value.text.isEmpty) { + if (_password.value.text.isEmpty) { toReturn = false; setState(() => unfilledPassword = true); } @@ -530,36 +473,457 @@ class _LogInPageState extends State { void clearFields() { unfilledUsername = false; unfilledPassword = false; + unfilledCode = false; _username.clear(); _password.clear(); + _code.clear(); + } + + Future runLogin() async { + if (allLoginFieldsValid()) { + Map payload = { + 'username': _username.value.text.trim(), + 'password': _password.value.text.trim() + }; + + try { + final ret = await Authentication.login(payload); + if (ret.statusCode == 200) { + var tokens = json.decode(ret.body); + user.defineTokens(tokens); + + return await retrieveUserData(); + } else { + int errorCode = getLogInError(ret.statusCode); + if (errorCode == 3) { + user.username = _username.value.text.trim(); + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Account not verified'), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + elevation: 15, + actions: [ + TextButton( + onPressed: () { + user.username = _username.value.text; + Navigator.restorablePushReplacementNamed( + context, '/verification'); + }, + child: const Text( + 'OK', + style: TextStyle(color: Colors.red, fontSize: 18), + ), + ), + ], + content: Column( + mainAxisSize: MainAxisSize.min, + children: const [ + Flexible( + child: Text( + 'Your account is not verified!\nPress OK to be taken to the verification page')), + ]), + ); + }); + return false; + } + } + } catch (e) { + errorMessage = 'Could not connect to server'; + print('Could not connect to /auth/user'); + return false; + } + } + return false; + } + + Future retrieveUserData() async { + bool success = false; + do { + final res = await User.getUser(); + if (res.statusCode == 200) { + var data = json.decode(res.body); + user.defineUserData(data); + user.setPassword(_password.value.text.trim()); + return true; + } else { + int errorCode = await getDataRetrieveError(res.statusCode); + if (errorCode == 3) { + errorDialog(context); + return false; + } + } + } while(!success); } - String getLogInError(int statusCode) { + int getLogInError(int statusCode) { switch (statusCode) { case 400: - return "Incorrect formatting!"; + errorMessage = "Incorrect formatting!"; + return 1; case 401: - return 'Password is incorrect'; + errorMessage = 'Password is incorrect'; + return 2; case 403: - return 'Account not verified'; + errorMessage = 'Account not verified'; + return 3; case 404: - return 'User not found'; + errorMessage = 'User not found'; + return 4; default: - return 'Something in auth went wrong!'; + return 5; } } - String getDataRetrieveError(int statusCode) { + Future getDataRetrieveError(int statusCode) async { switch (statusCode) { case 400: - return "Incorrect formatting!"; + errorMessage = "Incorrect formatting!"; + return 1; case 401: - return 'Token is invalid'; + errorMessage = 'Reconnecting...'; + setState(() {}); + if (await tryTokenRefresh()) { + errorMessage = 'Reconnected'; + return 2; + } else { + errorMessage = 'Could not connect to server!'; + + return 3; + } case 404: - return 'User Not Found'; + errorMessage = 'User not found'; + return 4; default: - return 'Something in auth went wrong!'; + return 5; + } + } + + final _email = TextEditingController(); + bool unfilledEmail = false; + bool codeSent = false; + + final _code = TextEditingController(); + bool unfilledCode = false; + + Widget buildForgot() { + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(10), + width: MediaQuery.of(context).size.width / 1.6, + height: MediaQuery.of(context).size.height / 2.3, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(35)), + color: Colors.black.withOpacity(.45), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: MediaQuery.of(context).size.width, + child: TextButton( + style: TextButton.styleFrom( + textStyle: const TextStyle( + fontSize: 18, + color: textFieldBorder, + decoration: TextDecoration.underline, + ), + ), + onPressed: () { + clearFields(); + errorMessage = ''; + setState(() => state = 0); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Icon( + Icons.navigate_before, + ), + Text('Go Back'), + ], + ), + ), + ), + Container( + width: 210, + padding: const EdgeInsets.only(top: 15), + child: const Text( + 'Email', + style: TextStyle( + fontSize: 12, + color: Colors.white, + fontFamily: 'EagleLake'), + textAlign: TextAlign.left, + ), + ), + SizedBox( + width: MediaQuery.of(context).size.width, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 210, + height: 40, + child: TextField( + maxLines: 1, + readOnly: codeSent, + controller: _email, + decoration: unfilledEmail + ? invalidTextField.copyWith( + hintText: 'Enter Email') + : globalDecoration.copyWith( + hintText: 'Enter Email'), + style: textFieldFontStyle, + onChanged: (email) { + if (email.isEmpty) { + setState(() => unfilledUsername = true); + } else { + if (isEmail(email)) { + errorMessage = ''; + setState(() => unfilledUsername = false); + } else { + errorMessage = + 'Email must be in proper format'; + setState(() => unfilledUsername = true); + } + } + }, + onSubmitted: (reset) async { + bool done = await sendResetCode(); + if (done) { + setState(() => codeSent = true); + } + }, + textInputAction: codeSent ? TextInputAction.next : TextInputAction.done, + ), + ) + ], + ), + ), + if (codeSent) + Container( + width: 210, + padding: const EdgeInsets.only(top: 10), + child: const Text( + 'Password', + style: TextStyle( + fontSize: 12, + color: Colors.white, + fontFamily: 'EagleLake'), + textAlign: TextAlign.left, + ), + ), + if (codeSent) + SizedBox( + width: MediaQuery.of(context).size.width, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 210, + height: 40, + child: TextField( + maxLines: 1, + controller: _password, + obscureText: true, + decoration: unfilledPassword + ? invalidTextField.copyWith( + hintText: 'Enter Password') + : globalDecoration.copyWith( + hintText: 'Enter Password'), + style: textFieldFontStyle, + onChanged: (password) { + if (password.isEmpty) { + setState(() => unfilledPassword = true); + } else { + errorMessage = ''; + setState(() => unfilledPassword = false); + } + }, + textInputAction: TextInputAction.next, + ), + ), + ], + ), + ), + if (codeSent) + Container( + width: 210, + padding: const EdgeInsets.only(top: 10), + child: const Text( + 'Code', + style: TextStyle( + fontSize: 12, + color: Colors.white, + fontFamily: 'EagleLake'), + textAlign: TextAlign.left, + ), + ), + if (codeSent) + SizedBox( + width: MediaQuery.of(context).size.width, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 210, + height: 40, + child: TextField( + maxLines: 1, + controller: _code, + obscureText: true, + decoration: unfilledCode + ? invalidTextField.copyWith( + hintText: 'Enter Code') + : globalDecoration.copyWith( + hintText: 'Enter Code'), + style: textFieldFontStyle, + onChanged: (code) { + if (code.isEmpty) { + setState(() => unfilledCode = true); + } else { + errorMessage = ''; + setState(() => unfilledCode = false); + } + }, + onSubmitted: (sub) async { + bool logged = await resetPassword(); + if (logged) { + errorMessage = 'Password reset Successful!'; + await messageDelay; + setState(() => clearFields()); + Navigator.pop(context); + } + }, + textInputAction: TextInputAction.done, + ), + ), + ], + ), + ), + SizedBox( + width: MediaQuery.of(context).size.width, + child: Text( + errorMessage, + style: const TextStyle(fontSize: 14, color: Colors.red), + textAlign: TextAlign.center, + ), + ), + Container( + padding: const EdgeInsets.only(top: 10, bottom: 10), + width: MediaQuery.of(context).size.width, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () async { + if (codeSent) { + bool logged = await resetPassword(); + if (logged) { + errorMessage = 'Password reset Successful!'; + await messageDelay; + setState(() => clearFields()); + Navigator.pop(context); + } + } else { + bool done = await sendResetCode(); + if (done) { + setState(() => codeSent = true); + } + } + + }, + style: buttonStyle, + child: Text( + codeSent ? 'Reset Password' : 'Send Code', + style: const TextStyle( + fontSize: 14, + color: Colors.white, + fontFamily: 'EagleLake'), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ], + ); + } + + Future sendResetCode() async { + if (validateEmail()) { + Map payload = { + 'email': _email.value.text.trim(), + }; + final ret = await Authentication.requestResetCode(payload); + if (ret.statusCode == 200) { + errorMessage = 'Code sent!'; + return true; + } else { + errorMessage = 'Account not found'; + } + } + return false; + } + + bool validateEmail() { + bool toRet = true; + if (_email.value.text.isEmpty) { + errorMessage = 'Email cannot be left blank'; + toRet = false; + } + if (!isEmail(_email.value.text)) { + errorMessage = 'Email must be in valid form'; + toRet = false; + } + return toRet; + } + + Future resetPassword() async { + if (validateForgotFields()) { + Map payload = { + 'email': _email.value.text.trim(), + 'password': _password.value.text.trim(), + 'code': int.parse(_code.value.text.trim()), + }; + try { + final ret = await Authentication.resetPassword(payload); + if (ret.statusCode == 200) { + return true; + } else { + return false; + } + } catch(e) { + print(e.toString()); + throw Exception('Something went wrong'); + } + } else return false; + } + + bool validateForgotFields() { + bool toRet = true; + if (_code.text.isEmpty) { + errorMessage = 'Code cannot be left blank'; + toRet = false; } + if (_password.text.isEmpty) { + errorMessage = 'Password cannot be left blank'; + toRet = false; + } + return toRet; } } @@ -593,6 +957,8 @@ class _RegisterPageState extends State { String errorMessage = ''; String topMessage = 'Welcome\nTo SmartChef!'; + XFile? image; + @override Widget build(BuildContext context) { return Container( @@ -615,20 +981,42 @@ class _RegisterPageState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ Container( - margin: const EdgeInsets.only(top: 5, bottom: 50), + margin: const EdgeInsets.symmetric(vertical: 25), padding: const EdgeInsets.all(8), - width: MediaQuery.of(context).size.width, - decoration: BoxDecoration( - borderRadius: - const BorderRadius.all(Radius.circular(35)), - color: Colors.black.withOpacity(.45)), - child: Text( - topMessage, - style: const TextStyle( - fontSize: 48, - color: Colors.white, - fontFamily: 'EagleLake'), - textAlign: TextAlign.center, + width: MediaQuery.of(context).size.width / 2, + height: MediaQuery.of(context).size.width / 2, + color: Colors.grey, + child: OutlinedButton( + onPressed: () async { + XFile? imageSrc = await _getImageFromGallery(); + if (imageSrc != null) { + image = imageSrc; + } + }, + child: image == null ? Center( + child: Column( + children: const [ + Icon( + Icons.upload, + size: bottomIconSize, + color: black, + ), + Flexible( + child: Text( + 'Click to upload a profile image', + style: TextStyle( + fontSize: 20, + color: white, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + ) : Image.file( + File(image!.path), + fit: BoxFit.contain, + ), ), ), Column( @@ -957,7 +1345,6 @@ class _RegisterPageState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ SizedBox( - width: 85, height: 36, child: ElevatedButton( onPressed: () async { @@ -1112,6 +1499,15 @@ class _RegisterPageState extends State { return 'Something went wrong!'; } } + + Future _getImageFromGallery() async { + XFile? pickedFile = await ImagePicker().pickImage( + source: ImageSource.gallery, + ); + if (pickedFile != null) { + return pickedFile; + } + } } class VerificationPage extends StatefulWidget { @@ -1246,7 +1642,6 @@ class _VerificationPageState extends State { ), ), SizedBox( - width: 100, height: 36, child: ElevatedButton( onPressed: () async { @@ -1256,13 +1651,14 @@ class _VerificationPageState extends State { } else { Map payload = { 'username': user.username.trim(), - 'verificationCode': + 'code': int.parse(_code.value.text.trim()) }; try { final res = await Authentication.verifyCode(payload); + if (res.statusCode == 200) { errorMessage = 'Account successfully created!'; await Future.delayed(const Duration(seconds: 1)); @@ -1271,7 +1667,9 @@ class _VerificationPageState extends State { Navigator.restorablePushReplacementNamed( context, '/login'); } else { - if (res.statusCode == 401) { + String message = json.decode(res.body); + if (message == "Verification code is either expired or not issued.") { + print('res.body'); Map name = { 'username': user.username, }; diff --git a/lib/screens/UsersProfileScreen.dart b/lib/screens/UsersProfileScreen.dart index 7412fba..430631a 100644 --- a/lib/screens/UsersProfileScreen.dart +++ b/lib/screens/UsersProfileScreen.dart @@ -1,7 +1,9 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; +import 'package:image_picker/image_picker.dart'; import 'package:smart_chef/utils/APIutils.dart'; import 'package:smart_chef/utils/authAPI.dart'; import 'package:smart_chef/utils/colors.dart'; @@ -33,9 +35,6 @@ class UserProfilePage extends StatefulWidget { class _UserProfilePageState extends State { @override void initState() { - setState(() { - shoppingCartPage = true; - }); super.initState(); } @@ -50,7 +49,7 @@ class _UserProfilePageState extends State { style: TextStyle(fontSize: 24, color: mainScheme), ), centerTitle: true, - backgroundColor: Colors.white, + backgroundColor: white, leading: IconButton( onPressed: () async { try { @@ -60,13 +59,13 @@ class _UserProfilePageState extends State { setState(() { errorMessage = 'Logout successful!'; }); - await Future.delayed(Duration(seconds: 1)); + await messageDelay; user.clear(); Navigator.pushNamedAndRemoveUntil( context, '/startup', ((Route route) => false)); } else { - errorMessage = getLogoutError(res.statusCode); + int errorCode = await getLogoutError(res.statusCode); } } catch (e) { print('Could not connect to server'); @@ -94,7 +93,7 @@ class _UserProfilePageState extends State { Navigator.pushNamedAndRemoveUntil( context, '/startup', ((Route route) => false)); } else { - errorMessage = getDeleteError(res.statusCode); + int errorCode = await getDeleteError(res.statusCode); } } catch (e) { errorDialog(context); @@ -115,7 +114,7 @@ class _UserProfilePageState extends State { child: Container( width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, - decoration: const BoxDecoration(color: Colors.white), + decoration: const BoxDecoration(color: white), child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -127,9 +126,21 @@ class _UserProfilePageState extends State { color: Colors.grey, border: Border.all(color: Colors.black, width: 2), ), + child: user.profileImage.isEmpty + ? const Text( + 'No profile image', + style: TextStyle( + fontSize: 20, + color: white, + ), + ) + : Image.network( + user.profileImage, + fit: BoxFit.fitWidth, + ), ), Container( - padding: EdgeInsets.symmetric(vertical: 10), + padding: const EdgeInsets.symmetric(vertical: 10), width: 200, child: const Text( 'Your profile image', @@ -174,7 +185,7 @@ class _UserProfilePageState extends State { : user.firstName, style: const TextStyle( fontSize: 20, - color: Colors.white, + color: white, ), textAlign: TextAlign.left, ), @@ -208,7 +219,7 @@ class _UserProfilePageState extends State { user.lastName.isEmpty ? '' : user.lastName, style: const TextStyle( fontSize: 20, - color: Colors.white, + color: white, ), textAlign: TextAlign.left, ), @@ -244,7 +255,7 @@ class _UserProfilePageState extends State { user.email.isEmpty ? '' : user.email, style: const TextStyle( fontSize: 20, - color: Colors.white, + color: white, ), textAlign: TextAlign.left, ), @@ -278,7 +289,7 @@ class _UserProfilePageState extends State { user.username.isEmpty ? '' : user.username, style: const TextStyle( fontSize: 20, - color: Colors.white, + color: white, ), textAlign: TextAlign.left, ), @@ -300,14 +311,14 @@ class _UserProfilePageState extends State { child: ElevatedButton( onPressed: () { Navigator.restorablePushNamed( - context, '/user/edit'); + context, '/user/edit'); }, style: buttonStyle, child: const Text( 'Edit Information', style: TextStyle( fontSize: 18, - color: Colors.white, + color: white, fontWeight: FontWeight.w400, ), textAlign: TextAlign.center, @@ -322,14 +333,14 @@ class _UserProfilePageState extends State { child: ElevatedButton( onPressed: () { Navigator.restorablePushNamed( - context, '/user/changePassword'); + context, '/user/changePassword'); }, style: buttonStyle, child: const Text( 'Change Password', style: TextStyle( fontSize: 18, - color: Colors.white, + color: white, fontWeight: FontWeight.w400, ), textAlign: TextAlign.center, @@ -354,7 +365,7 @@ class _UserProfilePageState extends State { decoration: BoxDecoration( border: Border( top: BorderSide(color: Colors.black.withOpacity(.2), width: 3)), - color: Colors.white, + color: white, ), child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -366,7 +377,8 @@ class _UserProfilePageState extends State { children: [ IconButton( onPressed: () { - Navigator.restorablePushReplacementNamed(context, '/food'); + Navigator.restorablePushReplacementNamed( + context, '/food'); }, icon: const Icon(Icons.egg), iconSize: bottomIconSize, @@ -374,7 +386,7 @@ class _UserProfilePageState extends State { ), Text( 'Ingredients', - style: bottomRowOnScreenTextStyle, + style: bottomRowIconTextStyle, textAlign: TextAlign.center, ) ], @@ -387,7 +399,8 @@ class _UserProfilePageState extends State { children: [ IconButton( onPressed: () { - Navigator.restorablePushReplacementNamed(context, '/recipe'); + Navigator.restorablePushReplacementNamed( + context, '/recipe'); }, icon: const Icon(Icons.restaurant), iconSize: bottomIconSize, @@ -408,7 +421,8 @@ class _UserProfilePageState extends State { children: [ IconButton( onPressed: () { - Navigator.restorablePushReplacementNamed(context, '/cart'); + Navigator.restorablePushReplacementNamed( + context, '/cart'); }, icon: const Icon(Icons.shopping_cart), iconSize: bottomIconSize, @@ -448,27 +462,46 @@ class _UserProfilePageState extends State { ); } - String getLogoutError(int statusCode) { + Future getLogoutError(int statusCode) async { switch (statusCode) { case 400: - return "Could not logout"; + errorMessage = 'Incorrect request format'; + return 1; case 401: - return 'Access Token invalid'; + errorMessage = 'Reconnecting...'; + if (await tryTokenRefresh()) { + errorMessage = 'Successfully changed password!'; + return 2; + } else { + errorMessage = 'Cannot connect to server'; + return 3; + } default: - return 'Something went wrong!'; + errorMessage = 'Something went wrong!'; + return 3; } } - String getDeleteError(int statusCode) { + Future getDeleteError(int statusCode) async { switch (statusCode) { case 400: - return 'Incorrect request format'; + errorMessage = 'Incorrect request format'; + return 1; case 401: - return 'Token is invalid'; + errorMessage = 'Reconnecting...'; + if (await tryTokenRefresh()) { + errorMessage = 'Successfully changed password!'; + return 2; + } else { + errorMessage = 'Cannot connect to server'; + return 3; + } case 404: - return 'User not found'; + errorMessage = 'User not found'; + return 3; default: - return 'Something went wrong!'; + errorMessage = 'Something went wrong!'; + return 3; } } } @@ -484,9 +517,13 @@ class _EditUserProfilePageState extends State { _firstName.text = user.firstName; _lastName.text = user.lastName; _username.text = user.username; + image = user.profileImage; super.initState(); } + String? image; + XFile? newImage; + final _firstName = TextEditingController(); bool unfilledFirstName = false; @@ -513,7 +550,7 @@ class _EditUserProfilePageState extends State { style: TextStyle(fontSize: 24, color: mainScheme), ), centerTitle: true, - backgroundColor: Colors.white, + backgroundColor: white, leading: IconButton( onPressed: () { Navigator.pop(context); @@ -531,27 +568,53 @@ class _EditUserProfilePageState extends State { child: Container( width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, - decoration: const BoxDecoration(color: Colors.white), + decoration: const BoxDecoration(color: white), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( - width: 200, - height: 200, - margin: const EdgeInsets.only(top: 20), - decoration: BoxDecoration( - color: Colors.grey, - border: Border.all(color: Colors.black, width: 2), - ), - ), - Container( - padding: const EdgeInsets.symmetric(vertical: 10), - width: 200, - child: const Text( - 'Your profile image', - style: TextStyle(fontSize: 14, color: Colors.black), - textAlign: TextAlign.center, + margin: const EdgeInsets.symmetric(vertical: 25), + padding: const EdgeInsets.all(8), + width: MediaQuery.of(context).size.width / 2, + height: MediaQuery.of(context).size.width / 2, + color: Colors.grey, + child: OutlinedButton( + onPressed: () async { + XFile? imageSrc = await _getImageFromGallery(); + if (imageSrc != null) { + newImage = imageSrc; + setState(() {}); + } + }, + child: newImage != null + ? Image.file( + File(newImage!.path), + fit: BoxFit.contain, + ) + : (image != null + ? Image.network(image!) + : Center( + child: Column( + children: const [ + Icon( + Icons.upload, + size: bottomIconSize, + color: black, + ), + Flexible( + child: Text( + 'Click to upload a profile image', + style: TextStyle( + fontSize: 20, + color: white, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + )), ), ), Container( @@ -676,9 +739,9 @@ class _EditUserProfilePageState extends State { controller: _username, decoration: unfilledUsername ? invalidTextField.copyWith( - hintText: 'Enter Username') + hintText: 'Enter Username') : globalDecoration.copyWith( - hintText: 'Enter Username'), + hintText: 'Enter Username'), style: textFieldFontStyle, textAlign: TextAlign.left, onChanged: (username) { @@ -761,10 +824,19 @@ class _EditUserProfilePageState extends State { onChanged: (password) { if (validatePassword(password)) { errorMessage = ''; - setState(() => unfilledConfirmPassword = false); + setState( + () => unfilledConfirmPassword = false); } else { errorMessage = 'Passwords must match!'; - setState(() => unfilledConfirmPassword = true); + setState( + () => unfilledConfirmPassword = true); + } + setState(() {}); + }, + onSubmitted: (done) async { + bool done = await doUpdate(); + if (done) { + Navigator.pop(context); } setState(() {}); }, @@ -779,59 +851,14 @@ class _EditUserProfilePageState extends State { ), ), Container( - padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 10), + padding: const EdgeInsets.symmetric( + vertical: 5, horizontal: 10), child: ElevatedButton( onPressed: () async { - if (allEditProfileFieldsValid()) { - if (passwordsMatch()) { - Map changes = { - 'firstName': _firstName.value.text.trim(), - 'lastName': _lastName.value.text.trim(), - 'lastSeen' : 1, - 'email' : user.email.trim(), - 'username': _username.value.text.trim(), - 'password': user.password, - }; - - try { - final res = - await User.updateUser(changes); - if (res.statusCode == 200) { - user.firstName = - _firstName.value.text.trim(); - user.lastName = - _lastName.value.text.trim(); - user.username = _username.value.text.trim(); - - errorMessage == - 'Successfully updated your profile!'; - await Future.delayed(const Duration(seconds: 1)); - - clearFields(); - Navigator.pop(context); - } - errorMessage = await getUpdateProfileError(res.statusCode); - if (res.statusCode == 403) { - if (errorMessage == - 'Successfully updated your profile!') { - user.firstName = - _firstName.value.text.trim(); - user.lastName = - _lastName.value.text.trim(); - user.username = _username.value.text.trim(); - - await Future.delayed(const Duration(seconds: 1)); - - clearFields(); - Navigator.pop(context); - } else { - errorDialog(context); - } - } - } catch (e) { - print('Could not connect to server'); - } - } + bool done1 = await doUpdate(); + bool done2 = await updatePFP(); + if (done1 && done2) { + Navigator.pop(context); } setState(() {}); }, @@ -840,7 +867,7 @@ class _EditUserProfilePageState extends State { 'Confirm Changes', style: TextStyle( fontSize: 18, - color: Colors.white, + color: white, fontWeight: FontWeight.w400, ), textAlign: TextAlign.center, @@ -864,7 +891,7 @@ class _EditUserProfilePageState extends State { decoration: BoxDecoration( border: Border( top: BorderSide(color: Colors.black.withOpacity(.2), width: 3)), - color: Colors.white, + color: white, ), child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -876,7 +903,8 @@ class _EditUserProfilePageState extends State { children: [ IconButton( onPressed: () { - Navigator.restorablePushReplacementNamed(context, '/food'); + Navigator.restorablePushReplacementNamed( + context, '/food'); }, icon: const Icon(Icons.egg), iconSize: bottomIconSize, @@ -897,7 +925,8 @@ class _EditUserProfilePageState extends State { children: [ IconButton( onPressed: () { - Navigator.restorablePushReplacementNamed(context, '/recipe'); + Navigator.restorablePushReplacementNamed( + context, '/recipe'); }, icon: const Icon(Icons.restaurant), iconSize: bottomIconSize, @@ -918,7 +947,8 @@ class _EditUserProfilePageState extends State { children: [ IconButton( onPressed: () { - Navigator.restorablePushReplacementNamed(context, '/cart'); + Navigator.restorablePushReplacementNamed( + context, '/cart'); }, icon: const Icon(Icons.shopping_cart), iconSize: bottomIconSize, @@ -958,6 +988,87 @@ class _EditUserProfilePageState extends State { ); } + Future doUpdate() async { + if (allEditProfileFieldsValid()) { + if (passwordsMatch()) { + Map changes = {}; + if (_firstName.value.text.trim() != user.firstName) { + changes['firstName'] = _firstName.value.text.trim(); + } + if (_lastName.value.text.trim() != user.lastName) { + changes['lastName'] = _lastName.value.text.trim(); + } + if (_username.value.text.trim() != user.username) { + changes['username'] = _username.value.text.trim(); + } + + if (changes.isEmpty) { + errorMessage = 'Nothing to update!'; + return false; + } + + try { + bool success = false; + do { + final res = await User.updateUser(changes); + if (res.statusCode == 200) { + user.firstName = _firstName.value.text.trim(); + user.lastName = _lastName.value.text.trim(); + user.username = _username.value.text.trim(); + + errorMessage == 'Successfully updated your profile!'; + await Future.delayed(const Duration(seconds: 1)); + + clearFields(); + return true; + } + int errorCode = await getUpdateProfileError(res.statusCode); + if (errorCode == 3) { + errorDialog(context); + } + } while (!success); + } catch (e) { + print('Could not connect to server'); + } + throw Exception('Could not update'); + } else + return false; + } else + return false; + } + + Future updatePFP() async { + if (newImage != null) { + if (user.profileImage.isNotEmpty) { + final res = await User.deleteProfileImage(); + if (res.statusCode != 200) { + errorMessage = json.decode(res.body); + } + } + var imageFile = File(newImage!.path); + String imageBase64 = base64Encode(imageFile.readAsBytesSync()); + bool success = false; + do { + Map payload = { + 'imgAsBase64': imageBase64 + }; + + final ret = await User.newProfileImage(payload); + if (ret.statusCode == 200) { + var data = json.decode(ret.body); + user.defineProfileImage(data); + return true; + } else { + int errorCode = await updatePFPError(ret.statusCode); + if (errorCode == 3) { + errorDialog(context); + } + } + } while (!success); + } + return true; + } + bool validatePassword(String password) { if (password.isEmpty) { return false; @@ -1016,21 +1127,85 @@ class _EditUserProfilePageState extends State { _confirmPassword.clear(); } - Future getUpdateProfileError(int statusCode) async { + Future getChangePasswordError(int statusCode) async { + switch (statusCode) { + case 400: + errorMessage = "Incorrect formatting!"; + return 1; + case 401: + errorMessage = 'Access token missing'; + return 1; + case 403: + errorMessage = 'Reconnecting...'; + if (await tryTokenRefresh()) { + errorMessage = 'Reconnected'; + return 2; + } else { + errorMessage = 'Could not connect to server!'; + return 3; + } + case 404: + errorMessage = 'User not found!'; + return 3; + default: + errorMessage = 'Service temporarily unavailable!'; + return 3; + } + } + + Future getUpdateProfileError(int statusCode) async { switch (statusCode) { case 400: - return "Username already taken"; + errorMessage = "Username already taken"; + return 1; case 401: errorMessage = 'Reconnecting...'; + setState(() {}); if (await tryTokenRefresh()) { - return 'Successfully changed password!'; + errorMessage = 'Reconnected'; + return 2; } else { - return 'Cannot connect to server'; + errorMessage = 'Could not connect to server!'; + return 3; } case 404: - return 'User not found'; + errorMessage = 'User not found!'; + return 3; default: - return 'Something in edit password went wrong!'; + errorMessage = 'Service temporarily unavailable!'; + return 3; + } + } + + Future updatePFPError(int statusCode) async { + switch (statusCode) { + case 400: + errorMessage = "Incorrect formatting!"; + return 1; + case 401: + errorMessage = 'Reconnecting...'; + if (await tryTokenRefresh()) { + errorMessage = 'Reconnected'; + return 2; + } else { + errorMessage = 'Could not connect to server!'; + return 3; + } + case 404: + errorMessage = 'User not found!'; + return 3; + default: + errorMessage = 'Service temporarily unavailable!'; + return 3; + } + } + + Future _getImageFromGallery() async { + XFile? pickedFile = await ImagePicker().pickImage( + source: ImageSource.gallery, + ); + if (pickedFile != null) { + return pickedFile; } } } @@ -1066,7 +1241,7 @@ class _EditPasswordPageState extends State { style: TextStyle(fontSize: 24, color: mainScheme), ), centerTitle: true, - backgroundColor: Colors.white, + backgroundColor: white, leading: IconButton( onPressed: () { Navigator.pop(context); @@ -1085,7 +1260,7 @@ class _EditPasswordPageState extends State { child: Container( width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, - decoration: const BoxDecoration(color: Colors.white), + decoration: const BoxDecoration(color: white), child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -1236,41 +1411,40 @@ class _EditPasswordPageState extends State { onPressed: () async { if (validateConfirmPassword()) { Map changes = { - 'email' : user.email, + 'email': user.email, 'firstName': user.firstName.trim(), 'lastName': user.lastName.trim(), - 'lastSeen' : -1, + 'lastSeen': -1, 'username': user.email.trim(), 'password': _newPassword.value.text.trim(), }; try { - final res = await User.updateUser(changes); - if (res.statusCode == 200) { - user.password = - _newPassword.value.text.trim(); - - errorMessage = - 'Successfully updated your password!'; - await Future.delayed( - const Duration(seconds: 1)); + bool success = false; + do { + final res = + await User.updateUser(changes); + if (res.statusCode == 200) { + user.password = + _newPassword.value.text.trim(); - clearFields(); - Navigator.pop(context); - } - errorMessage = await getChangePasswordError( - res.statusCode); - if (errorMessage == - 'Successfully updated your password!') { - user.password = - _newPassword.value.text.trim(); - await Future.delayed( - const Duration(seconds: 1)); + errorMessage = + 'Successfully updated your password!'; + await Future.delayed( + const Duration(seconds: 1)); - clearFields(); - Navigator.pop(context); - } - errorDialog(context); + clearFields(); + Navigator.pop(context); + } + int errorCode = + await getChangePasswordError( + res.statusCode); + if (errorCode == 3) { + clearFields(); + Navigator.pop(context); + } + errorDialog(context); + } while (!success); } catch (e) { print('Could not connect to server'); } @@ -1283,7 +1457,7 @@ class _EditPasswordPageState extends State { 'Confirm Changes', style: TextStyle( fontSize: 18, - color: Colors.white, + color: white, fontWeight: FontWeight.w300, ), textAlign: TextAlign.center, @@ -1307,7 +1481,7 @@ class _EditPasswordPageState extends State { decoration: BoxDecoration( border: Border( top: BorderSide(color: Colors.black.withOpacity(.2), width: 3)), - color: Colors.white, + color: white, ), child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -1319,7 +1493,8 @@ class _EditPasswordPageState extends State { children: [ IconButton( onPressed: () { - Navigator.restorablePushReplacementNamed(context, '/food'); + Navigator.restorablePushReplacementNamed( + context, '/food'); }, icon: const Icon(Icons.egg), iconSize: bottomIconSize, @@ -1340,7 +1515,8 @@ class _EditPasswordPageState extends State { children: [ IconButton( onPressed: () { - Navigator.restorablePushReplacementNamed(context, '/recipe'); + Navigator.restorablePushReplacementNamed( + context, '/recipe'); }, icon: const Icon(Icons.restaurant), iconSize: bottomIconSize, @@ -1361,7 +1537,8 @@ class _EditPasswordPageState extends State { children: [ IconButton( onPressed: () { - Navigator.restorablePushReplacementNamed(context, '/cart'); + Navigator.restorablePushReplacementNamed( + context, '/cart'); }, icon: const Icon(Icons.shopping_cart), iconSize: bottomIconSize, @@ -1457,23 +1634,29 @@ class _EditPasswordPageState extends State { _confirmPassword.clear(); } - Future getChangePasswordError(int statusCode) async { + Future getChangePasswordError(int statusCode) async { switch (statusCode) { case 400: - return "Incorrect formatting!"; + errorMessage = "Incorrect formatting!"; + return 1; case 401: - return 'Access token missing'; + errorMessage = 'Access token missing'; + return 1; case 403: errorMessage = 'Reconnecting...'; if (await tryTokenRefresh()) { - return 'Successfully updated your password!'; + errorMessage = 'Reconnected'; + return 2; } else { - return 'Cannot connect to server'; + errorMessage = 'Could not connect to server!'; + return 3; } case 404: - return 'User not found'; + errorMessage = 'User not found!'; + return 3; default: - return 'Something in edit password went wrong!'; + errorMessage = 'Service temporarily unavailable!'; + return 3; } } } diff --git a/lib/utils/APIutils.dart b/lib/utils/APIutils.dart index dafcf7a..648dff1 100644 --- a/lib/utils/APIutils.dart +++ b/lib/utils/APIutils.dart @@ -1,14 +1,18 @@ import 'dart:convert'; import 'dart:io'; -import 'package:http/http.dart' as http; import 'package:smart_chef/utils/authAPI.dart'; import 'package:smart_chef/utils/userData.dart'; const String API_PREFIX = "https://api-smart-chef.herokuapp.com/"; final baseHeader = {HttpHeaders.contentTypeHeader: 'application/json'}; +final accessTokenHeader = { + HttpHeaders.contentTypeHeader: 'application/json', + HttpHeaders.authorizationHeader: user.accessToken +}; UserData user = UserData.create(); +final messageDelay = Future.delayed(Duration(seconds: 1)); RegExp emailValidation = RegExp( r'^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$'); @@ -43,8 +47,8 @@ Future refreshTokenStatus() async { switch (changeToken.statusCode) { case 200: var tokens = json.decode(changeToken.body); - user.accessToken = tokens['accessToken']; - user.refreshToken = tokens['refreshToken']; + user.accessToken = tokens['accessToken']['token']; + user.refreshToken = tokens['refreshToken']['token']; return true; case 400: return false; @@ -69,6 +73,7 @@ Future reauthenticateUser() async { var data = json.decode(response.body); user.accessToken = data['accessToken']; user.refreshToken = data['refreshToken']; + print('Successful relog'); return true; case 400: print('Incorrect request format'); @@ -82,6 +87,8 @@ Future reauthenticateUser() async { case 404: print('User not found'); break; + default: + print('Cannot connect!'); } return false; } diff --git a/lib/utils/authAPI.dart b/lib/utils/authAPI.dart index c944775..7b8d65f 100644 --- a/lib/utils/authAPI.dart +++ b/lib/utils/authAPI.dart @@ -26,7 +26,7 @@ class Authentication { try { Map tokenBody = {'refreshToken': user.refreshToken}; - response = await http.put(Uri.parse('$API_PREFIX$apiRoute/refreshJWT'), + response = await http.post(Uri.parse('$API_PREFIX$apiRoute/refreshJWT'), body: json.encode(tokenBody), headers: baseHeader); } catch (e) { @@ -99,4 +99,34 @@ class Authentication { return response; } + + static Future requestResetCode(Map payload) async { + http.Response response; + + try { + response = await http.post(Uri.parse('$API_PREFIX$apiRoute/request-password-reset'), + body: json.encode(payload), + headers: baseHeader); + } catch (e) { + print(e.toString()); + throw Exception('Could not connect to server'); + } + + return response; + } + + static Future resetPassword(Map payload) async { + http.Response response; + + try { + response = await http.post(Uri.parse('$API_PREFIX$apiRoute/perform-password-reset'), + body: json.encode(payload), + headers: baseHeader); + } catch (e) { + print(e.toString()); + throw Exception('Could not connect to server'); + } + + return response; + } } diff --git a/lib/utils/colors.dart b/lib/utils/colors.dart index 6beead2..84cf9a1 100644 --- a/lib/utils/colors.dart +++ b/lib/utils/colors.dart @@ -5,3 +5,5 @@ const Color textFieldBacking = Color(0xffD9D9D9); const Color bottomRowIcon = Color(0xff5E5E5E); const Color textFieldBorder = Color(0xff47A1E2); const Color searchFieldText = Color(0xff3E3E3E); +const Color white = Colors.white; +const Color black = Colors.black; diff --git a/lib/utils/globals.dart b/lib/utils/globals.dart index 1cc02d3..2bf5296 100644 --- a/lib/utils/globals.dart +++ b/lib/utils/globals.dart @@ -7,12 +7,8 @@ const double bottomRowHeight = 90; const double topBarIconSize = 28; const double ingredientInfoFontSize = 18; const double addIngredientPageTextSize = 32; - -bool ingredientPage = true; -bool recipePage = false; -bool shoppingCartPage = false; -bool userProfilePage = false; const double roundedCorner = 15; +const double searchIconButtonSize = 20; final globalDecoration = InputDecoration( contentPadding: const EdgeInsets.fromLTRB(5, 1, 5, 1), @@ -25,6 +21,10 @@ final globalDecoration = InputDecoration( TextStyle bottomRowIconTextStyle = const TextStyle(fontSize: 12, color: bottomRowIcon); TextStyle bottomRowOnScreenTextStyle = const TextStyle(fontSize: 12, color: mainScheme); +TextStyle ingredientInfoTextStyle = const TextStyle( + fontSize: ingredientInfoFontSize, + color: black, +); final buttonStyle = ElevatedButton.styleFrom( shape: RoundedRectangleBorder( @@ -32,12 +32,12 @@ final buttonStyle = ElevatedButton.styleFrom( BorderRadius.circular(roundedCorner), ), backgroundColor: mainScheme, - shadowColor: Colors.black, + shadowColor: black, ); TextStyle textFieldFontStyle = const TextStyle( fontSize: 20, - color: Colors.black, + color: black, ); TextStyle errorTextStyle = const TextStyle(fontSize: 10, color: Colors.red); @@ -141,3 +141,8 @@ final invalidTextField = globalDecoration.copyWith( enabledBorder: const OutlineInputBorder(borderSide: BorderSide(color: Colors.red)), suffixIcon: const Icon(Icons.clear, color: Colors.red)); + +class AlwaysDisabledFocusNode extends FocusNode { + @override + bool get hasFocus => false; +} diff --git a/lib/utils/ingredientAPI.dart b/lib/utils/ingredientAPI.dart new file mode 100644 index 0000000..742f015 --- /dev/null +++ b/lib/utils/ingredientAPI.dart @@ -0,0 +1,54 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:smart_chef/utils/APIutils.dart'; + +class Ingredients { + static const String apiRoute = 'ingredients'; + + static Future searchIngredients(String searchQuery, int resultsPerPage, int page, String intolerance) async { + http.Response response; + + String totalUrl = '$API_PREFIX$apiRoute?ingredientName=$searchQuery'; + if (resultsPerPage != 0) { + totalUrl += '&resultsPerPage=$resultsPerPage'; + } + if (page != 0) { + totalUrl += '&page=$page'; + } + if (intolerance.isNotEmpty) { + totalUrl += '&intolerance=$intolerance'; + } + + try { + response = await http.get(Uri.parse(totalUrl), + headers: baseHeader); + } catch (e) { + print(e.toString()); + throw Exception('Could not connect to server'); + } + + return response; + } + + static Future getIngredientByID(int ingredientID, int quantity, String unit) async { + http.Response response; + + String totalUrl = '$API_PREFIX$apiRoute/$ingredientID'; + if (quantity != 0) { + totalUrl += '&quantity=$quantity'; + } + if (unit.isNotEmpty) { + totalUrl += '&unit=$unit'; + } + + try { + response = await http.get(Uri.parse(totalUrl), + headers: baseHeader); + } catch (e) { + print(e.toString()); + throw Exception('Could not connect to server'); + } + + return response; + } +} diff --git a/lib/utils/ingredientData.dart b/lib/utils/ingredientData.dart new file mode 100644 index 0000000..91d457a --- /dev/null +++ b/lib/utils/ingredientData.dart @@ -0,0 +1,132 @@ +class IngredientData { + + int ID; + String name; + String category; + List units; + List nutrients; + int expirationDate; + String imageUrl; + + IngredientData(this.ID, this.name, this.category, this.units, this.nutrients, this.expirationDate, this.imageUrl); + + factory IngredientData.create() { + IngredientData origin = IngredientData(0, '', '', [], [], 0, ''); + return origin; + } + + IngredientData baseIngredient(Map json) { + this.ID = json['id']; + this.name = json['name']; + this.category = json.containsKey('category') ? json['category'] : ''; + this.imageUrl = json.containsKey('image') ? (json['image'].containsKey('srcUrl') ? json['image']['srcUrl'] : '') : ''; + return this; + } + + IngredientData completeIngredient(Map json) { + this.ID = json['id']; + this.name = json['name']; + this.category = json.containsKey('category') ? json['category'] : ''; + this.imageUrl = json.containsKey('image') ? (json['image'].containsKey('srcUrl') ? json['image']['srcUrl'] : '') : ''; + this.units = insertUnits(json); + this.nutrients = Nutrient.create().toNutrient(json); + return this; + } + + List insertUnits(Map json) { + List units = []; + for (var unit in json['quantityUnits']) { + units.add(unit); + } + return units; + } + + IngredientData inventoryIngredient(Map json) { + this.ID = json['id']; + this.name = json['name']; + this.category = json.containsKey('category') ? json['category'] : ''; + this.imageUrl = json.containsKey('image') ? json['image']['srcUrl'] : ''; + this.expirationDate = json['expirationDate']; + return this; + } + + void addInformationToIngredient(Map json) { + this.units = insertUnits(json); + this.nutrients = Nutrient.create().toNutrient(json); + } + + Map toJson() => { + 'id': this.ID, + 'name': this.name, + 'category': this.category, + 'nutrients': this.nutrients, + 'quantityUnits': this.units, + }; + + void clear() { + this.ID = 0; + this.name = ''; + this.category = ''; + this.nutrients = []; + this.units = []; + this.expirationDate = 0; + } + + @override + String toString() { + String toString = ''; + if (this.name.isNotEmpty) { + toString += 'Name: ${this.name}'; + } + if (this.ID != 0) { + toString += '\nID: ${this.ID}'; + } + if (this.category.isNotEmpty) { + toString += '\nCategories: ${this.category}'; + } + return toString; + } +} + +class Nutrient{ + + String name; + Unit unit; + num percentOfDaily; + + Nutrient(this.name, this.unit, this.percentOfDaily); + + factory Nutrient.create() { + Nutrient origin = Nutrient('', Unit.create(), 0); + return origin; + } + + List toNutrient(Map json) { + List nutrient = []; + for (var nutrients in json['nutrients']) { + nutrient.add(Nutrient(nutrients['name'], Unit(nutrients['unit']['unit'], nutrients['unit']['value']), nutrients['percentOfDaily'])); + } + return nutrient; + } + + @override + String toString() { + return '${this.name}: ${this.unit.toString()}; Percent of daily value: ${this.percentOfDaily}\n'; + } +} + +class Unit{ + String unit; + num value; + + Unit(this.unit, this.value); + + factory Unit.create() { + Unit origin = Unit('', 0); + return origin; + } + + String toString() { + return '${this.value} ${this.unit}'; + } +} diff --git a/lib/utils/inventoryAPI.dart b/lib/utils/inventoryAPI.dart new file mode 100644 index 0000000..489e48d --- /dev/null +++ b/lib/utils/inventoryAPI.dart @@ -0,0 +1,99 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:smart_chef/utils/APIutils.dart'; + +class Inventory { + static const String apiRoute = 'user/inventory'; + + static Future retrieveUserInventory(bool isReverse, bool sortByExpirationDate, bool sortByCategory, bool sortByLexicographicalOrder) async { + http.Response response; + + String totalUrl = '$API_PREFIX$apiRoute?'; + if (isReverse) { + totalUrl += '&isReverse=true'; + } else { + totalUrl += '&isReverse=false'; + } + if (sortByExpirationDate) { + totalUrl += '&sortByExpirationDate=true'; + } else { + if (sortByCategory) { + totalUrl += '&sortByCategory=true'; + } else { + if (sortByLexicographicalOrder) { + totalUrl += '&sortByLexicographicalOrder=true'; + } + } + } + + try { + response = await http.get(Uri.parse(totalUrl), + headers: accessTokenHeader); + } catch (e) { + print(e.toString()); + throw Exception('Could not connect to server'); + } + + return response; + } + + static Future addIngredient(Map payload) async { + http.Response response; + + String totalUrl = '$API_PREFIX$apiRoute'; + + try { + response = await http.post(Uri.parse(totalUrl), + body: json.encode(payload), + headers: accessTokenHeader); + } catch (e) { + print(e.toString()); + throw Exception('Could not connect to server'); + } + + return response; + } + + static Future retrieveIngredientFromInventory(int id) async { + http.Response response; + + try { + response = await http.get(Uri.parse('$API_PREFIX$apiRoute/$id'), + headers: accessTokenHeader); + } catch (e) { + print(e.toString()); + throw Exception('Could not connect to server'); + } + + return response; + } + + static Future updateIngredientInInventory(int id, Map payload) async { + http.Response response; + + try { + response = await http.put(Uri.parse('$API_PREFIX$apiRoute/$id'), + body: json.encode(payload), + headers: accessTokenHeader); + } catch (e) { + print(e.toString()); + throw Exception('Could not connect to server'); + } + + return response; + } + + static Future deleteIngredientfromInventory(int id) async { + http.Response response; + + try { + response = await http.delete(Uri.parse('$API_PREFIX$apiRoute/$id'), + headers: accessTokenHeader); + } catch (e) { + print(e.toString()); + throw Exception('Could not connect to server'); + } + + return response; + } +} diff --git a/lib/utils/userAPI.dart b/lib/utils/userAPI.dart index fd459cc..e479d4f 100644 --- a/lib/utils/userAPI.dart +++ b/lib/utils/userAPI.dart @@ -1,6 +1,5 @@ -import 'dart:io'; -import 'package:http/http.dart' as http; import 'dart:convert'; +import 'package:http/http.dart' as http; import 'package:smart_chef/utils/APIutils.dart'; class User { @@ -10,13 +9,8 @@ class User { static Future getUser() async { http.Response response; - final headers = { - HttpHeaders.contentTypeHeader: 'application/json', - HttpHeaders.authorizationHeader: user.accessToken - }; - try { - response = await http.get(Uri.parse('$API_PREFIX$apiRoute'), headers: headers); + response = await http.get(Uri.parse('$API_PREFIX$apiRoute'), headers: accessTokenHeader); } catch (e) { print(e.toString()); throw Exception('Could not connect to server'); @@ -28,15 +22,10 @@ class User { static Future updateUser(Map changes) async { http.Response response; - final headers = { - HttpHeaders.contentTypeHeader: 'application/json', - HttpHeaders.authorizationHeader: user.accessToken - }; - try { response = await http.put(Uri.parse('$API_PREFIX$apiRoute'), body: json.encode(changes), - headers: headers); + headers: accessTokenHeader); } catch (e) { print(e.toString()); throw Exception('Could not connect to server'); @@ -48,13 +37,49 @@ class User { static Future deleteUser() async { http.Response response; - final headers = { - HttpHeaders.contentTypeHeader: 'application/json', - HttpHeaders.authorizationHeader: user.accessToken - }; + try { + response = await http.delete(Uri.parse('$API_PREFIX$apiRoute'), headers: accessTokenHeader); + } catch (e) { + print(e.toString()); + throw Exception('Could not connect to server'); + } + + return response; + } + + static Future getProfileImage() async { + http.Response response; + + try { + response = await http.get(Uri.parse('$API_PREFIX$apiRoute/profile-picture'), headers: accessTokenHeader); + } catch (e) { + print(e.toString()); + throw Exception('Could not connect to server'); + } + + return response; + } + + static Future newProfileImage(Map changes) async { + http.Response response; + + try { + response = await http.post(Uri.parse('$API_PREFIX$apiRoute/profile-picture'), + body: json.encode(changes), + headers: accessTokenHeader); + } catch (e) { + print(e.toString()); + throw Exception('Could not connect to server'); + } + + return response; + } + + static Future deleteProfileImage() async { + http.Response response; try { - response = await http.delete(Uri.parse('$API_PREFIX$apiRoute'), headers: headers); + response = await http.delete(Uri.parse('$API_PREFIX$apiRoute/profile-picture'), headers: accessTokenHeader); } catch (e) { print(e.toString()); throw Exception('Could not connect to server'); diff --git a/lib/utils/userData.dart b/lib/utils/userData.dart index 731bb79..96d78c9 100644 --- a/lib/utils/userData.dart +++ b/lib/utils/userData.dart @@ -1,6 +1,3 @@ -import 'dart:convert'; -import 'dart:io'; - class UserData { String firstName; @@ -11,12 +8,11 @@ class UserData { String accessToken; String refreshToken; // TODO(6): Add profile image support - //static late bool hasProfileImage; - //static late String profileImage; + String profileImage; - UserData(this.firstName, this.lastName, this.username, this.email, this.password, this.accessToken, this.refreshToken); + UserData(this.firstName, this.lastName, this.username, this.email, this.password, this.accessToken, this.refreshToken, this.profileImage); - static final UserData origin = UserData('', '', '', '', '', '', ''); + static final UserData origin = UserData('', '', '', '', '', '', '', ''); factory UserData.create() { return origin; @@ -29,9 +25,13 @@ class UserData { this.email = json['email']; } + void defineProfileImage(Map json) { + this.profileImage = json.containsKey('srcUrl') ? json['srcUrl'] : ''; + } + void defineTokens(Map json) { - this.accessToken = json['accessToken']; - this.refreshToken = json['refreshToken']; + this.accessToken = json['accessToken']['token']; + this.refreshToken = json['refreshToken']['token']; } void setPassword(String pass) { @@ -42,7 +42,8 @@ class UserData { 'firstName': this.firstName, 'lastName': this.lastName, 'lastSeen': 1, - 'username': this.email, + 'username': this.username, + 'email': this.email, 'password': this.password, }; diff --git a/pubspec.lock b/pubspec.lock index d1fe104..47349d2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -50,6 +50,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.5" + dropdown_button2: + dependency: "direct main" + description: + name: dropdown_button2 + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.1" fake_async: dependency: transitive description: @@ -135,6 +142,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.6.2" + intl: + dependency: "direct main" + description: + name: intl + url: "https://pub.dartlang.org" + source: hosted + version: "0.17.0" js: dependency: transitive description: @@ -247,4 +261,4 @@ packages: version: "2.1.2" sdks: dart: ">=2.18.2 <3.0.0" - flutter: ">=2.10.0" + flutter: ">=3.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 2fadc15..0b60463 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,8 @@ dependencies: cupertino_icons: ^1.0.2 http: ^0.13.5 image_picker: ^0.8.6 + dropdown_button2: ^1.9.1 + intl: ^0.17.0 dev_dependencies: flutter_test: diff --git a/test/widget_test.dart b/test/widget_test.dart index 8a773dc..77b1cbf 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -13,7 +13,7 @@ import 'package:smart_chef/main.dart'; void main() { testWidgets('Counter increments smoke test', (WidgetTester tester) async { // Build our app and trigger a frame. - await tester.pumpWidget(MyApp()); + await tester.pumpWidget(SmartChef()); // Verify that our counter starts at 0. expect(find.text('0'), findsOneWidget);