diff --git a/avon_farm_foods.iml b/avon_farm_foods.iml index a700c39..0d882de 100644 --- a/avon_farm_foods.iml +++ b/avon_farm_foods.iml @@ -11,6 +11,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/avon_farm_foods_android.iml b/avon_farm_foods_android.iml index 0ca70ed..b9d5740 100644 --- a/avon_farm_foods_android.iml +++ b/avon_farm_foods_android.iml @@ -19,8 +19,8 @@ - + - + \ No newline at end of file diff --git a/images/home.jpg b/images/home.jpg new file mode 100644 index 0000000..899636c Binary files /dev/null and b/images/home.jpg differ diff --git a/images/splash_screen.png b/images/splash_screen.png new file mode 100644 index 0000000..65d0ade Binary files /dev/null and b/images/splash_screen.png differ diff --git a/lib/enums/clear_basket_dialog_action.dart b/lib/enums/clear_basket_dialog_action.dart new file mode 100644 index 0000000..76e0a20 --- /dev/null +++ b/lib/enums/clear_basket_dialog_action.dart @@ -0,0 +1,4 @@ +enum ClearBasketDialogAction { + no, + yes, +} diff --git a/lib/enums/theme.dart b/lib/enums/theme.dart new file mode 100644 index 0000000..a444f80 --- /dev/null +++ b/lib/enums/theme.dart @@ -0,0 +1,4 @@ +enum AppTheme { + dark, + light, +} diff --git a/lib/main.dart b/lib/main.dart index 9c0a43f..8acd3c6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,15 +1,108 @@ +import 'dart:convert'; + +import 'package:avon_farm_foods/enums/theme.dart'; +import 'package:avon_farm_foods/models/configuration.dart'; +import 'package:avon_farm_foods/pages/basket.dart'; +import 'package:avon_farm_foods/pages/checkout.dart'; import 'package:avon_farm_foods/pages/home.dart'; +import 'package:avon_farm_foods/pages/orders.dart'; +import 'package:avon_farm_foods/pages/products.dart'; +import 'package:avon_farm_foods/pages/settings.dart'; import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + runApp( + new FutureBuilder( + builder: ( + BuildContext context, + AsyncSnapshot snapshot, + ) { + return snapshot.hasData ? new MyApp(snapshot.data) : new SplashScreen(); + }, + future: SharedPreferences.getInstance(), + ), + ); +} + +class MyApp extends StatefulWidget { + MyApp(this.preferences); + + final SharedPreferences preferences; + + @override + _MyAppState createState() => new _MyAppState(); +} + +class _MyAppState extends State { + Configuration get _configuration { + if (widget.preferences.getString('configuration') == null) { + Configuration configuration = new Configuration(); + + _configurationUpdater(configuration); + + return configuration; + } else { + return new Configuration.fromJson( + JSON.decode( + widget.preferences.getString('configuration'), + ), + ); + } + } + + ThemeData get _theme { + switch (_configuration.theme) { + case AppTheme.dark: + return new ThemeData.dark(); + case AppTheme.light: + return new ThemeData.light(); + default: + return null; + } + } -void main() => runApp(new MyApp()); + void _configurationUpdater(Configuration configuration) { + setState(() { + widget.preferences.setString( + 'configuration', + JSON.encode(configuration), + ); + }); + } -class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return new MaterialApp( home: new HomePage(), - theme: new ThemeData.dark(), + routes: { + '/basket': (BuildContext context) => new BasketPage(), + '/checkout': (BuildContext context) => new CheckoutPage(), + '/orders': (BuildContext context) => new OrdersPage(), + '/products': (BuildContext context) => new ProductsPage(), + '/settings': (BuildContext context) { + return new SettingsPage( + _configuration, + _configurationUpdater, + ); + }, + }, + theme: _theme, title: 'Avon Farm Foods', ); } } + +class SplashScreen extends StatelessWidget { + @override + Widget build(BuildContext context) { + return new MaterialApp( + home: new Container( + child: new Image.asset( + 'images/splash_screen.png', + fit: BoxFit.cover, + ), + ), + ); + } +} diff --git a/lib/models/checkout.dart b/lib/models/checkout.dart new file mode 100644 index 0000000..1001bd8 --- /dev/null +++ b/lib/models/checkout.dart @@ -0,0 +1,10 @@ +class Checkout { + String address; + int cardNumber; + int cvv; + String expiryDate; + String firstName; + String lastName; + String postcode; + int telephone; +} diff --git a/lib/models/configuration.dart b/lib/models/configuration.dart new file mode 100644 index 0000000..ad6f589 --- /dev/null +++ b/lib/models/configuration.dart @@ -0,0 +1,19 @@ +import 'package:avon_farm_foods/enums/theme.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'configuration.g.dart'; + +@JsonSerializable() +class Configuration extends Object with _$ConfigurationSerializerMixin { + Configuration({this.theme = AppTheme.dark}) : assert(theme != null); + + final AppTheme theme; + + factory Configuration.fromJson(Map json) { + return _$ConfigurationFromJson(json); + } + + Configuration copyWith({AppTheme theme}) { + return new Configuration(theme: theme ?? this.theme); + } +} diff --git a/lib/models/configuration.g.dart b/lib/models/configuration.g.dart new file mode 100644 index 0000000..24766e3 --- /dev/null +++ b/lib/models/configuration.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'configuration.dart'; + +// ************************************************************************** +// Generator: JsonSerializableGenerator +// ************************************************************************** + +Configuration _$ConfigurationFromJson(Map json) => + new Configuration( + theme: json['theme'] == null + ? null + : AppTheme.values.singleWhere( + (x) => x.toString() == "AppTheme.${json['theme']}")); + +abstract class _$ConfigurationSerializerMixin { + AppTheme get theme; + Map toJson() => { + 'theme': theme == null ? null : theme.toString().split('.')[1] + }; +} diff --git a/lib/models/order.dart b/lib/models/order.dart new file mode 100644 index 0000000..063d400 --- /dev/null +++ b/lib/models/order.dart @@ -0,0 +1,11 @@ +import 'package:avon_farm_foods/models/product.dart'; + +class Order { + Order({this.id, this.date, this.delivery, this.products, this.total}); + + final int id; + final DateTime date; + final DateTime delivery; + final List products; + final double total; +} diff --git a/lib/models/page.dart b/lib/models/page.dart new file mode 100644 index 0000000..6358ef5 --- /dev/null +++ b/lib/models/page.dart @@ -0,0 +1,9 @@ +import 'package:flutter/material.dart'; + +class Page { + Page({this.icon, this.route, this.title}); + + final Icon icon; + final String route; + final String title; +} diff --git a/lib/models/product.dart b/lib/models/product.dart new file mode 100644 index 0000000..2198305 --- /dev/null +++ b/lib/models/product.dart @@ -0,0 +1,30 @@ +class Product implements Comparable { + Product({ + this.description, + this.id, + this.isFavourite = false, + this.isInBasket = false, + this.isPopular = false, + this.name, + this.price, + this.quantity = 0, + this.url, + }); + + final String description; + final int id; + final bool isPopular; + final String name; + final double price; + final String url; + + bool isFavourite; + bool isInBasket; + int quantity; + + @override + int compareTo(Product other) => id - other.id; + + bool operator ==(other) => other is Product && other.id == id; + int get hashCode => id.hashCode; +} diff --git a/lib/pages/basket.dart b/lib/pages/basket.dart new file mode 100644 index 0000000..63cf6ed --- /dev/null +++ b/lib/pages/basket.dart @@ -0,0 +1,149 @@ +import 'package:avon_farm_foods/enums/clear_basket_dialog_action.dart'; +import 'package:avon_farm_foods/models/product.dart'; +import 'package:avon_farm_foods/stores/basket.dart'; +import 'package:avon_farm_foods/widgets/dismissible_list_tile.dart'; +import 'package:avon_farm_foods/widgets/drawer.dart'; +import 'package:avon_farm_foods/widgets/product_list_tile.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_flux/flutter_flux.dart'; +import 'package:numberpicker/numberpicker.dart'; + +class BasketPage extends StatefulWidget { + @override + _BasketPageState createState() => new _BasketPageState(); +} + +class _BasketPageState extends State + with StoreWatcherMixin { + BasketStore _basketStore; + List _products; + + @override + void initState() { + super.initState(); + + _basketStore = listenToStore(basketStoreToken, _handleBasketStoreChanged); + + _products = _basketStore.products; + } + + AppBar _buildAppBar() { + return new AppBar( + actions: _buildAppBarActions(), + title: new Text('Basket'), + ); + } + + List _buildAppBarActions() { + return [ + new IconButton( + icon: new Icon(Icons.delete), + onPressed: _basketStore.isEmpty + ? null + : () { + showDialog( + context: context, + child: _buildClearBasketDialog(), + ).then((value) { + if (value == ClearBasketDialogAction.yes) clearBasket(); + }); + }, + tooltip: 'Clear Basket', + ), + new IconButton( + icon: new Icon(Icons.payment), + onPressed: _basketStore.isEmpty + ? null + : () => Navigator.of(context).pushNamed('/checkout'), + tooltip: 'Enter Checkout', + ), + ]; + } + + AlertDialog _buildClearBasketDialog() { + return new AlertDialog( + actions: _buildClearBasketDialogActions(), + content: new Text('Are you sure you want to clear your basket?'), + title: new Text('Clear Basket'), + ); + } + + List _buildClearBasketDialogActions() { + return [ + new ButtonTheme.bar( + child: new ButtonBar( + children: [ + _buildDialogButton('NO', ClearBasketDialogAction.no), + _buildDialogButton('YES', ClearBasketDialogAction.yes), + ], + ), + ), + ]; + } + + FlatButton _buildDialogButton(String text, dynamic action) { + return new FlatButton( + child: new Text(text), + onPressed: () { + return Navigator.pop( + context, + action, + ); + }, + ); + } + + NumberPickerDialog _buildEditQuantityDialog(int quantity) { + return new NumberPickerDialog.integer( + initialIntegerValue: quantity, + maxValue: 99, + minValue: 1, + title: new Text('Edit Quantity'), + ); + } + + void _handleBasketStoreChanged(BasketStore basketStore) { + _products = basketStore.products; + + setState(() {}); + } + + void _onQuantityPressed(Product product) { + showDialog( + child: _buildEditQuantityDialog(product.quantity), + context: context, + ).then((int quantity) { + product.quantity = quantity; + }); + } + + @override + Widget build(BuildContext context) { + return new Scaffold( + appBar: _buildAppBar(), + body: new ListView( + children: _products.map((Product product) { + return new DismissibleListTileWidget( + child: new ProductListTileWidget( + onQuantityPressed: () => _onQuantityPressed(product), + product: product, + ), + dismissedSnackBar: new SnackBar( + action: new SnackBarAction( + label: 'UNDO', + onPressed: () => addProductToBasket(product), + ), + content: new Text('Product Removed'), + ), + dismissibleBackgroundIcon: new Icon(Icons.delete), + key: new ObjectKey(product), + onDismissed: (DismissDirection direction) { + removeProductFromBasket(product); + }, + ); + }).toList(), + ), + drawer: new DrawerWidget(), + ); + } +} diff --git a/lib/pages/checkout.dart b/lib/pages/checkout.dart new file mode 100644 index 0000000..35af430 --- /dev/null +++ b/lib/pages/checkout.dart @@ -0,0 +1,213 @@ +import 'package:avon_farm_foods/models/checkout.dart'; +import 'package:avon_farm_foods/stores/basket.dart'; +import 'package:avon_farm_foods/widgets/form_section_divider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_flux/flutter_flux.dart'; + +class CheckoutPage extends StatefulWidget { + @override + _CheckoutPageState createState() => new _CheckoutPageState(); +} + +class _CheckoutPageState extends State + with StoreWatcherMixin { + BasketStore _basketStore; + Checkout _checkout = new Checkout(); + bool _shouldAutoValidate = false; + + final GlobalKey _formKey = new GlobalKey(); + final EdgeInsets _formSectionPadding = new EdgeInsets.fromLTRB( + 16.0, + 0.0, + 16.0, + 16.0, + ); + + @override + void initState() { + super.initState(); + + _basketStore = listenToStore(basketStoreToken); + } + + AppBar _buildAppBar() { + return new AppBar( + title: new Text('Checkout'), + actions: [ + new Column( + children: [ + new Container( + child: new Text('£${_basketStore.total.toStringAsFixed(2)}'), + padding: new EdgeInsets.only(right: 8.0), + ), + ], + mainAxisAlignment: MainAxisAlignment.center, + ), + ], + ); + } + + Form _buildForm() { + return new Form( + autovalidate: _shouldAutoValidate, + child: new SingleChildScrollView( + child: new Column( + children: [ + new FormSectionDividerWidget(title: 'Personal Details'), + new Container( + child: new Column( + children: [ + new TextFormField( + decoration: new InputDecoration( + labelText: 'First Name', + ), + onSaved: (String value) => _checkout.firstName = value, + validator: (String value) { + return value.isEmpty + ? 'Please enter your first name.' + : null; + }, + ), + new TextFormField( + decoration: new InputDecoration( + labelText: 'Last Name', + ), + onSaved: (String value) => _checkout.lastName = value, + validator: (String value) { + return value.isEmpty + ? 'Please enter your last name.' + : null; + }, + ), + ], + ), + padding: _formSectionPadding, + ), + new FormSectionDividerWidget(title: 'Delivery Details'), + new Container( + child: new Column( + children: [ + new TextFormField( + decoration: new InputDecoration( + labelText: 'Address', + ), + onSaved: (String value) => _checkout.address = value, + validator: (String value) { + return value.isEmpty + ? 'Please enter your delivery address.' + : null; + }, + ), + new TextFormField( + decoration: new InputDecoration( + labelText: 'Postcode', + ), + onSaved: (String value) => _checkout.postcode = value, + validator: (String value) { + return value.isEmpty + ? 'Please enter your delivery postcode.' + : null; + }, + ), + new TextFormField( + decoration: new InputDecoration( + labelText: 'Telephone', + ), + keyboardType: TextInputType.phone, + onSaved: (String value) { + return _checkout.telephone = int.parse(value); + }, + validator: (String value) { + return value.isEmpty + ? 'Please enter your contact telephone.' + : null; + }, + ), + ], + ), + padding: _formSectionPadding, + ), + new FormSectionDividerWidget(title: 'Payment Details'), + new Container( + child: new Column( + children: [ + new TextFormField( + decoration: new InputDecoration( + hintText: '1111222233334444', + labelText: 'Card Number', + ), + keyboardType: TextInputType.number, + onSaved: (String value) { + return _checkout.cardNumber = int.parse(value); + }, + validator: (String value) { + return value.isEmpty + ? 'Please enter your card number.' + : null; + }, + ), + new TextFormField( + decoration: new InputDecoration( + hintText: 'MM/YY', + labelText: 'Expiry Date', + ), + keyboardType: TextInputType.datetime, + onSaved: (String value) => _checkout.expiryDate = value, + validator: (String value) { + return value.isEmpty + ? 'Please enter your expiry date.' + : null; + }, + ), + new TextFormField( + decoration: new InputDecoration( + hintText: '123', + labelText: 'CVV Number', + ), + keyboardType: TextInputType.number, + onSaved: (String value) { + return _checkout.cvv = int.parse(value); + }, + validator: (String value) { + return value.isEmpty + ? 'Please enter your card verification value.' + : null; + }, + ), + ], + ), + padding: _formSectionPadding, + ), + new Container( + child: new RaisedButton( + child: new Text('Pay Now'), + onPressed: _handleSubmitted, + ), + padding: _formSectionPadding, + ), + ], + crossAxisAlignment: CrossAxisAlignment.stretch, + ), + ), + key: _formKey, + ); + } + + void _handleSubmitted() { + final FormState form = _formKey.currentState; + + if (!form.validate()) { + _shouldAutoValidate = true; + } else { + form.save(); + } + } + + @override + Widget build(BuildContext context) { + return new Scaffold( + appBar: _buildAppBar(), + body: _buildForm(), + ); + } +} diff --git a/lib/pages/home.dart b/lib/pages/home.dart index 3cfbc17..2ed25b5 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -1,3 +1,4 @@ +import 'package:avon_farm_foods/widgets/drawer.dart'; import 'package:flutter/material.dart'; class HomePage extends StatefulWidget { @@ -6,12 +7,69 @@ class HomePage extends StatefulWidget { } class _HomePageState extends State { + Column _buildIconColumn(IconData icon, String label) { + return new Column( + children: [ + new CircleAvatar( + child: new Icon( + icon, + size: 40.0, + ), + radius: 30.0, + ), + new Container( + child: new Text( + label, + style: new TextStyle( + fontSize: 16.0, + ), + ), + padding: new EdgeInsets.only(top: 8.0), + ), + ], + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + ); + } + @override Widget build(BuildContext context) { return new Scaffold( appBar: new AppBar( title: new Text('Avon Farm Foods'), ), + body: new ListView( + children: [ + new Image.asset( + 'images/home.jpg', + fit: BoxFit.cover, + ), + new Container( + child: new Text( + 'Welcome to Avon Farm Foods', + style: new TextStyle( + fontSize: 20.0, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + padding: new EdgeInsets.only(bottom: 32.0, top: 32.0), + ), + new Container( + child: new Row( + children: [ + _buildIconColumn(Icons.shopping_basket, 'Shopped'), + _buildIconColumn(Icons.credit_card, 'Secured'), + _buildIconColumn(Icons.local_shipping, 'Shipped'), + _buildIconColumn(Icons.sentiment_very_satisfied, 'Satisfied'), + ], + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + ), + ), + ], + padding: new EdgeInsets.all(16.0), + ), + drawer: new DrawerWidget(), ); } } diff --git a/lib/pages/orders.dart b/lib/pages/orders.dart new file mode 100644 index 0000000..2950a2c --- /dev/null +++ b/lib/pages/orders.dart @@ -0,0 +1,72 @@ +import 'package:avon_farm_foods/models/order.dart'; +import 'package:avon_farm_foods/models/product.dart'; +import 'package:avon_farm_foods/widgets/drawer.dart'; +import 'package:avon_farm_foods/widgets/order_list_tile.dart'; +import 'package:flutter/material.dart'; + +class OrdersPage extends StatefulWidget { + @override + _OrdersPageState createState() => new _OrdersPageState(); +} + +class _OrdersPageState extends State { + _OrdersPageState() { + _orders = new List.generate(25, (int index) { + return new Order( + date: new DateTime.now().subtract( + new Duration(days: index * 7), + ), + delivery: new DateTime.now().subtract( + new Duration(days: (index * 7) - 3), + ), + id: (25 - index) * 1000, + products: [ + new Product( + id: index + 1, + name: 'Product ${index + 1}', + price: 1.99, + url: 'images/home.jpg', + ), + ], + total: 1.99, + ); + }); + } + + List _orders; + + AppBar _buildAppBar() { + return new AppBar( + title: new Text('Orders'), + ); + } + + ListView _buildOrdersList() { + return new ListView.builder( + itemBuilder: (BuildContext context, int index) { + return new Container( + child: new OrderListTileWidget( + order: _orders[index], + ), + decoration: new BoxDecoration( + border: new Border( + bottom: new BorderSide( + color: Theme.of(context).dividerColor, + ), + ), + ), + ); + }, + itemCount: _orders.length, + ); + } + + @override + Widget build(BuildContext context) { + return new Scaffold( + appBar: _buildAppBar(), + body: _buildOrdersList(), + drawer: new DrawerWidget(), + ); + } +} diff --git a/lib/pages/products.dart b/lib/pages/products.dart new file mode 100644 index 0000000..8f17580 --- /dev/null +++ b/lib/pages/products.dart @@ -0,0 +1,191 @@ +import 'package:avon_farm_foods/models/product.dart'; +import 'package:avon_farm_foods/stores/basket.dart'; +import 'package:avon_farm_foods/types/predicate.dart'; +import 'package:avon_farm_foods/widgets/drawer.dart'; +import 'package:avon_farm_foods/widgets/predicate_tab.dart'; +import 'package:avon_farm_foods/widgets/product_card.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_flux/flutter_flux.dart'; + +class ProductsPage extends StatefulWidget { + @override + _ProductsPageState createState() => new _ProductsPageState(); +} + +class _ProductsPageState extends State + with StoreWatcherMixin { + _ProductsPageState() { + _products = new List.generate(999, (int index) { + return new Product( + description: 'Insert product description here.', + id: index + 1, + isPopular: (index + 1) % 100 == 0, + name: 'Product ${index + 1}', + price: 1.99, + url: 'images/home.jpg', + ); + }); + + _searchQueryPredicate = (Product product) { + if (_searchQuery.text.isEmpty) { + return true; + } else { + return product.name.contains( + new RegExp( + _searchQuery.text, + caseSensitive: false, + ), + ); + } + }; + } + + BasketStore _basketStore; + bool _isSearching = false; + List _products; + Predicate _searchQueryPredicate; + + final TextEditingController _searchQuery = new TextEditingController(); + + final List> _tabs = [ + new PredicateTabWidget( + icon: new Icon(Icons.list), + predicate: (Product product) => true, + text: 'ALL', + ), + new PredicateTabWidget( + icon: new Icon(Icons.thumb_up), + predicate: (Product product) => product.isPopular, + text: 'POPULAR', + ), + new PredicateTabWidget( + icon: new Icon(Icons.favorite), + predicate: (Product product) => product.isFavourite, + text: 'FAVOURITES', + ) + ]; + + @override + void initState() { + super.initState(); + + _basketStore = listenToStore(basketStoreToken); + + _products.forEach((Product product) { + if (_basketStore.contains(product)) product.isInBasket = true; + }); + } + + List _buildActions() { + return [ + new IconButton( + icon: new Icon(Icons.search), + onPressed: _handleSearchBegin, + tooltip: 'Search', + ), + ]; + } + + AppBar _buildAppBar() { + return new AppBar( + actions: _buildActions(), + bottom: _buildTabBar(), + title: new Text('Products'), + ); + } + + AppBar _buildSearchBar() { + return new AppBar( + bottom: _buildTabBar(), + leading: new BackButton(), + title: new TextField( + autofocus: true, + controller: _searchQuery, + decoration: new InputDecoration( + hintText: 'Search by product name', + ), + ), + ); + } + + TabBar _buildTabBar() { + return new TabBar( + tabs: _tabs, + ); + } + + TabBarView _buildTabBarView() { + return new TabBarView( + children: _tabs.map((PredicateTabWidget tab) { + return _products + .where(tab.predicate) + .where(_searchQueryPredicate) + .toList(); + }).map((List products) { + return new ListView.builder( + itemBuilder: (BuildContext context, int index) { + return new Container( + child: new ProductCardWidget( + onPressedMoreInfo: null, + onToggledFavourite: () { + setState(() { + products[index].isFavourite = !products[index].isFavourite; + }); + }, + onToggledInBasket: () { + setState(() { + if (products[index].isInBasket) { + removeProductFromBasket(products[index]); + + products[index].quantity = 0; + } else { + products[index].quantity = 1; + + addProductToBasket(products[index]); + } + + products[index].isInBasket = !products[index].isInBasket; + }); + }, + product: products[index], + ), + padding: new EdgeInsets.symmetric(vertical: 8.0), + ); + }, + itemCount: products.length, + padding: new EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + ); + }).toList(), + ); + } + + void _handleSearchBegin() { + ModalRoute.of(context).addLocalHistoryEntry(new LocalHistoryEntry( + onRemove: () { + setState(() { + _isSearching = false; + _searchQuery.clear(); + }); + }, + )); + + setState(() { + _isSearching = true; + }); + } + + @override + Widget build(BuildContext context) { + return new DefaultTabController( + child: new Scaffold( + appBar: _isSearching ? _buildSearchBar() : _buildAppBar(), + body: _buildTabBarView(), + drawer: new DrawerWidget(), + ), + length: _tabs.length, + ); + } +} diff --git a/lib/pages/settings.dart b/lib/pages/settings.dart new file mode 100644 index 0000000..f90da2f --- /dev/null +++ b/lib/pages/settings.dart @@ -0,0 +1,58 @@ +import 'package:avon_farm_foods/enums/theme.dart'; +import 'package:avon_farm_foods/models/configuration.dart'; +import 'package:avon_farm_foods/widgets/drawer.dart'; +import 'package:avon_farm_foods/widgets/form_section_divider.dart'; +import 'package:flutter/material.dart'; + +class SettingsPage extends StatefulWidget { + SettingsPage(this.configuration, this.updater); + + final Configuration configuration; + final ValueChanged updater; + + @override + _SettingsPageState createState() => new _SettingsPageState(); +} + +class _SettingsPageState extends State { + AppBar _buildAppBar() { + return new AppBar( + title: new Text('Settings'), + ); + } + + ListView _buildSettingsList() { + return new ListView( + children: [ + new FormSectionDividerWidget(title: 'Theme'), + _buildThemeRadioListTile('Dark', AppTheme.dark), + _buildThemeRadioListTile('Light', AppTheme.light), + ], + ); + } + + RadioListTile _buildThemeRadioListTile( + String title, + AppTheme value, + ) { + return new RadioListTile( + groupValue: widget.configuration.theme, + onChanged: _handleThemeChanged, + title: new Text(title), + value: value, + ); + } + + void _handleThemeChanged(AppTheme theme) { + widget.updater(widget.configuration.copyWith(theme: theme)); + } + + @override + Widget build(BuildContext context) { + return new Scaffold( + appBar: _buildAppBar(), + body: _buildSettingsList(), + drawer: new DrawerWidget(), + ); + } +} diff --git a/lib/stores/basket.dart b/lib/stores/basket.dart new file mode 100644 index 0000000..a0b678b --- /dev/null +++ b/lib/stores/basket.dart @@ -0,0 +1,39 @@ +import 'package:avon_farm_foods/models/product.dart'; +import 'package:flutter_flux/flutter_flux.dart'; + +class BasketStore extends Store { + BasketStore() { + triggerOnAction(addProductToBasket, (product) { + _products.add(product); + _products.sort(); + }); + + triggerOnAction(clearBasket, (_) { + _products.clear(); + }); + + triggerOnAction(removeProductFromBasket, (product) { + _products.remove(product); + }); + } + + final List _products = []; + + bool get isEmpty => _products.isEmpty; + List get products => new List.unmodifiable(_products); + double get total { + return _products.map((Product product) { + return product.price * product.quantity; + }).reduce((double accumulator, double currentValue) { + return accumulator + currentValue; + }); + } + + bool contains(Product product) => _products.contains(product); +} + +final StoreToken basketStoreToken = new StoreToken(new BasketStore()); + +final Action addProductToBasket = new Action(); +final Action clearBasket = new Action(); +final Action removeProductFromBasket = new Action(); diff --git a/lib/types/predicate.dart b/lib/types/predicate.dart new file mode 100644 index 0000000..3113eaf --- /dev/null +++ b/lib/types/predicate.dart @@ -0,0 +1 @@ +typedef bool Predicate(T obj); diff --git a/lib/widgets/dismissible_list_tile.dart b/lib/widgets/dismissible_list_tile.dart new file mode 100644 index 0000000..14217b4 --- /dev/null +++ b/lib/widgets/dismissible_list_tile.dart @@ -0,0 +1,60 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class DismissibleListTileWidget extends StatefulWidget { + DismissibleListTileWidget({ + @required this.child, + this.dismissedSnackBar, + @required this.dismissibleBackgroundIcon, + Key key, + @required this.onDismissed, + }) + : super(key: key); + + @override + _DismissibleListTileWidgetState createState() => + new _DismissibleListTileWidgetState(); + + final Widget child; + final SnackBar dismissedSnackBar; + final Icon dismissibleBackgroundIcon; + final DismissDirectionCallback onDismissed; +} + +class _DismissibleListTileWidgetState extends State { + Container _buildBackground() { + return new Container( + child: new ListTile( + trailing: widget.dismissibleBackgroundIcon, + ), + color: Theme.of(context).dividerColor, + ); + } + + @override + Widget build(BuildContext context) { + return new Dismissible( + background: _buildBackground(), + child: new Container( + child: widget.child, + decoration: new BoxDecoration( + border: new Border( + bottom: new BorderSide( + color: Theme.of(context).dividerColor, + ), + ), + color: Theme.of(context).canvasColor, + ), + ), + direction: DismissDirection.endToStart, + key: widget.key, + onDismissed: (DismissDirection direction) { + widget.onDismissed(direction); + + if (widget.dismissedSnackBar != null) { + Scaffold.of(context).showSnackBar(widget.dismissedSnackBar); + } + }, + ); + } +} diff --git a/lib/widgets/drawer.dart b/lib/widgets/drawer.dart new file mode 100644 index 0000000..d127883 --- /dev/null +++ b/lib/widgets/drawer.dart @@ -0,0 +1,47 @@ +import 'package:avon_farm_foods/models/page.dart'; +import 'package:avon_farm_foods/widgets/drawer_page.dart'; +import 'package:flutter/material.dart'; + +class DrawerWidget extends StatelessWidget { + final List _pages = [ + new Page( + icon: new Icon(Icons.home), + route: '/', + title: 'Home', + ), + new Page( + icon: new Icon(Icons.search), + route: '/products', + title: 'Products', + ), + new Page( + icon: new Icon(Icons.shopping_basket), + route: '/basket', + title: 'Basket', + ), + new Page( + icon: new Icon(Icons.receipt), + route: '/orders', + title: 'Orders', + ), + new Page( + icon: new Icon(Icons.settings), + route: '/settings', + title: 'Settings', + ), + ]; + + @override + Widget build(BuildContext context) { + return new Drawer( + child: new ListView( + children: _pages.map((Page page) { + return new DrawerPageWidget( + onTap: () => Navigator.of(context).pushNamed(page.route), + page: page, + ); + }).toList(), + ), + ); + } +} diff --git a/lib/widgets/drawer_page.dart b/lib/widgets/drawer_page.dart new file mode 100644 index 0000000..a814d98 --- /dev/null +++ b/lib/widgets/drawer_page.dart @@ -0,0 +1,29 @@ +import 'package:avon_farm_foods/models/page.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class DrawerPageWidget extends StatefulWidget { + DrawerPageWidget({ + Key key, + @required this.onTap, + @required this.page, + }) + : super(key: key); + + final VoidCallback onTap; + final Page page; + + @override + _DrawerPageWidgetState createState() => new _DrawerPageWidgetState(); +} + +class _DrawerPageWidgetState extends State { + @override + Widget build(BuildContext context) { + return new ListTile( + leading: widget.page.icon, + onTap: widget.onTap, + title: new Text(widget.page.title), + ); + } +} diff --git a/lib/widgets/form_section_divider.dart b/lib/widgets/form_section_divider.dart new file mode 100644 index 0000000..a6fcf74 --- /dev/null +++ b/lib/widgets/form_section_divider.dart @@ -0,0 +1,28 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class FormSectionDividerWidget extends StatelessWidget { + FormSectionDividerWidget({Key key, @required this.title}) : super(key: key); + + final String title; + + @override + Widget build(BuildContext context) { + return new Container( + child: new Container( + child: new Text( + title, + style: new TextStyle( + fontSize: 20.0, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + padding: new EdgeInsets.symmetric(vertical: 16.0), + ), + decoration: new BoxDecoration( + color: Theme.of(context).dividerColor, + ), + ); + } +} diff --git a/lib/widgets/order_list_tile.dart b/lib/widgets/order_list_tile.dart new file mode 100644 index 0000000..0191124 --- /dev/null +++ b/lib/widgets/order_list_tile.dart @@ -0,0 +1,25 @@ +import 'package:avon_farm_foods/models/order.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class OrderListTileWidget extends StatelessWidget { + OrderListTileWidget({ + Key key, + @required this.order, + }) + : super(key: key); + + final Order order; + + @override + Widget build(BuildContext context) { + return new ListTile( + isThreeLine: true, + subtitle: new Text( + '${order.date.toIso8601String().substring(0, 10)}\n${order.delivery.toIso8601String().substring(0, 10)}', + ), + title: new Text('${order.id}'), + trailing: new Text('£${order.total}'), + ); + } +} diff --git a/lib/widgets/predicate_tab.dart b/lib/widgets/predicate_tab.dart new file mode 100644 index 0000000..fe581a4 --- /dev/null +++ b/lib/widgets/predicate_tab.dart @@ -0,0 +1,15 @@ +import 'package:avon_farm_foods/types/predicate.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class PredicateTabWidget extends Tab { + PredicateTabWidget({ + Widget icon, + Key key, + @required this.predicate, + String text, + }) + : super(icon: icon, key: key, text: text); + + final Predicate predicate; +} diff --git a/lib/widgets/product_card.dart b/lib/widgets/product_card.dart new file mode 100644 index 0000000..2fb5f37 --- /dev/null +++ b/lib/widgets/product_card.dart @@ -0,0 +1,81 @@ +import 'package:avon_farm_foods/models/product.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class ProductCardWidget extends StatefulWidget { + ProductCardWidget({ + Key key, + @required this.onPressedMoreInfo, + @required this.onToggledFavourite, + @required this.onToggledInBasket, + @required this.product, + }) + : super(key: key); + + final VoidCallback onPressedMoreInfo; + final VoidCallback onToggledFavourite; + final VoidCallback onToggledInBasket; + final Product product; + + @override + _ProductCardWidgetState createState() => new _ProductCardWidgetState(); +} + +class _ProductCardWidgetState extends State { + @override + Widget build(BuildContext context) { + return new Card( + child: new Column( + children: [ + new Image.asset( + widget.product.url, + fit: BoxFit.cover, + ), + new ListTile( + leading: new CircleAvatar( + child: new Text( + '${widget.product.id.toString()}', + style: new TextStyle(fontSize: 14.0), + ), + ), + subtitle: new Text('£${widget.product.price.toStringAsFixed(2)}'), + title: new Text( + widget.product.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + new ButtonTheme.bar( + child: new ButtonBar( + children: [ + new IconButton( + icon: new Icon(Icons.info), + onPressed: widget.onPressedMoreInfo, + ), + new IconButton( + icon: new Icon( + Icons.shopping_basket, + color: widget.product.isInBasket + ? Theme.of(context).textTheme.title.color + : Theme.of(context).disabledColor, + ), + onPressed: widget.onToggledInBasket, + ), + new IconButton( + icon: new Icon( + Icons.favorite, + color: widget.product.isFavourite + ? Colors.red + : Theme.of(context).disabledColor, + ), + onPressed: widget.onToggledFavourite, + ), + ], + ), + ), + ], + mainAxisSize: MainAxisSize.min, + ), + ); + } +} diff --git a/lib/widgets/product_list_tile.dart b/lib/widgets/product_list_tile.dart new file mode 100644 index 0000000..99f970d --- /dev/null +++ b/lib/widgets/product_list_tile.dart @@ -0,0 +1,33 @@ +import 'package:avon_farm_foods/models/product.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class ProductListTileWidget extends StatelessWidget { + ProductListTileWidget({ + Key key, + @required this.onQuantityPressed, + @required this.product, + }) + : super(key: key); + + final VoidCallback onQuantityPressed; + final Product product; + + @override + Widget build(BuildContext context) { + return new ListTile( + leading: new CircleAvatar( + child: new Text( + '${product.id}', + style: new TextStyle(fontSize: 14.0), + ), + ), + subtitle: new Text('£${product.price.toStringAsFixed(2)}'), + title: new Text(product.name), + trailing: new FlatButton( + child: new Text('${product.quantity}'), + onPressed: onQuantityPressed, + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 7626871..7efb0d1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -36,6 +36,48 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.2" + build: + dependency: transitive + description: + name: build + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.0+2" + build_config: + dependency: transitive + description: + name: build_config + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.5" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.1" + build_runner: + dependency: "direct dev" + description: + name: build_runner + url: "https://pub.dartlang.org" + source: hosted + version: "0.7.11+1" + built_collection: + dependency: transitive + description: + name: built_collection + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.5" + built_value: + dependency: transitive + description: + name: built_value + url: "https://pub.dartlang.org" + source: hosted + version: "5.2.1" charcode: dependency: transitive description: @@ -50,6 +92,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.1.2+1" + code_builder: + dependency: transitive + description: + name: code_builder + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.3" collection: dependency: transitive description: @@ -85,11 +134,32 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.1.1" + dart_style: + dependency: transitive + description: + name: dart_style + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.9+1" + fixnum: + dependency: transitive + description: + name: fixnum + url: "https://pub.dartlang.org" + source: hosted + version: "0.10.7" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_flux: + dependency: "direct main" + description: + path: "..\\flutter_flux" + relative: true + source: path + version: "4.0.1" flutter_test: dependency: "direct dev" description: flutter @@ -109,6 +179,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.5" + graphs: + dependency: transitive + description: + name: graphs + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.0" html: dependency: transitive description: @@ -158,6 +235,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.6.1" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.2" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.2" kernel: dependency: transitive description: @@ -214,6 +305,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.4.0" + numberpicker: + dependency: "direct main" + description: + name: numberpicker + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.0" package_config: dependency: transitive description: @@ -263,6 +361,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.28.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.0" shelf: dependency: transitive description: @@ -296,6 +401,13 @@ packages: description: flutter source: sdk version: "0.0.99" + source_gen: + dependency: transitive + description: + name: source_gen + url: "https://pub.dartlang.org" + source: hosted + version: "0.7.6" source_map_stack_trace: dependency: transitive description: @@ -331,6 +443,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.6.3" + stream_transform: + dependency: transitive + description: + name: stream_transform + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.10" string_scanner: dependency: transitive description: @@ -395,4 +514,5 @@ packages: source: hosted version: "2.1.13" sdks: - dart: ">=2.0.0-dev.23.0 <=2.0.0-edge.0d5cf900b021bf5c9fa593ffa12b15bcd1cc5fe0" + dart: ">=2.0.0-dev.28.0 <=2.0.0-edge.0d5cf900b021bf5c9fa593ffa12b15bcd1cc5fe0" + flutter: ">=0.1.4 <2.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 14e83be..90ad6e5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,14 +5,21 @@ dependencies: flutter: sdk: flutter - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. + flutter_flux: + path: ../flutter_flux + cupertino_icons: ^0.1.0 + json_annotation: ^0.2.0 + numberpicker: ^0.1.0 + shared_preferences: ^0.4.0 dev_dependencies: flutter_test: sdk: flutter + build_runner: ^0.7.0 + json_serializable: ^0.3.0 + # For information on the generic Dart part of this file, see the # following page: https://www.dartlang.org/tools/pub/pubspec @@ -26,9 +33,9 @@ flutter: uses-material-design: true # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + assets: + - images/home.jpg + - images/splash_screen.png # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.io/assets-and-images/#resolution-aware.