From 308ca5ec976b1b9d1bfc75ec6d5358fd0c49dc8c Mon Sep 17 00:00:00 2001 From: Jeremiah Ogbomo Date: Fri, 27 Jul 2018 05:03:48 +0100 Subject: [PATCH] release: 1.1.0 (#73) * chore: set jobs sort to active by default * fix: typos * refactor: redux dry (#67) * fix: typos * refactor: sort algo * refactor: logout * refactor: type-safe reducers [WIP] * refactor: code clean up * refactor: keep store clean & DRY * chore: update TASKS * fix: minor bugs (#68) * chore: track amount left on payment creation * refactor: proper keyboard actions * chore: UI for additional note on job * fix: handle on image picker canceled * chore: dispose of FocusNode * fix: snackbar loader width * chore: update TASKS * fix: adding multiple contact * refactor: fetch settings before silent login * refactor(redux): added settings * refactor: homepage & splash * refactor: add WillPopScope to Homepage * chore: update TASKS * feat: personalized measures (#69) * chore: first look * chore: refactor + second look * chore: delete block * chore: code clean up * chore: edit measure item * chore: code clean up * refactor(redux): measures * chore: implementation details * chore(redux): clean up implementation details * chore: code clean up * chore: code clean up * chore: code clean up * fix: settings no network issue * chore: still cleaning up * chore: link contacts to account measures * feat: personalize measurements w/ account * chore: update TASKS * chore: code clean up * fix: minor bugs (#70) * feat: in app review (#71) * chore: first look * chore: set up redux * chore(redux): code clean up * chore: code cleanup * chore: code clean up * chore: update TASKS * release: 1.1.0 (#72) * chore: added verified icon * refactor: contact upload state snack * feat: bump version to 1.1.0 --- .vscode/settings.json | 1 + TASKS.todo | 34 +- android/app/build.gradle | 4 +- assets/images/verified.png | Bin 0 -> 1107 bytes lib/models/account.dart | 12 + lib/models/contact.dart | 21 +- lib/models/job.dart | 28 +- lib/models/main.dart | 4 + lib/models/measure.dart | 72 ++-- lib/pages/accounts/measures.dart | 55 --- lib/pages/accounts/ui/measures_create.dart | 149 -------- lib/pages/contacts/contact.dart | 79 ++--- lib/pages/contacts/contacts.dart | 10 +- lib/pages/contacts/contacts_create.dart | 136 +++++--- lib/pages/contacts/contacts_edit.dart | 7 +- lib/pages/contacts/ui/contact_appbar.dart | 10 +- lib/pages/contacts/ui/contact_form.dart | 59 +++- lib/pages/contacts/ui/contact_jobs_list.dart | 31 -- lib/pages/contacts/ui/contact_measure.dart | 31 +- .../contacts/ui/contacts_filter_button.dart | 4 + lib/pages/contacts/ui/contacts_list_item.dart | 142 ++++---- lib/pages/gallery/gallery_view.dart | 29 +- lib/pages/homepage/home_view_model.dart | 28 +- lib/pages/homepage/homepage.dart | 118 ++++--- lib/pages/homepage/ui/create_button.dart | 2 +- lib/pages/homepage/ui/review_modal.dart | 66 ++++ lib/pages/homepage/ui/top_button_bar.dart | 48 ++- lib/pages/jobs/job.dart | 225 ++++++------ lib/pages/jobs/jobs.dart | 3 - lib/pages/jobs/jobs_create.dart | 53 ++- lib/pages/jobs/ui/gallery_grids.dart | 15 +- lib/pages/jobs/ui/measure_lists.dart | 50 --- lib/pages/jobs/ui/measures.dart | 42 --- lib/pages/jobs/ui/payment_grids.dart | 12 +- lib/pages/measures/measures.dart | 54 +++ lib/pages/measures/measures_create.dart | 325 ++++++++++++++++++ lib/pages/measures/measures_manage.dart | 112 ++++++ .../ui/measure_create_items.dart | 93 +++-- lib/pages/measures/ui/measure_dialog.dart | 118 +++++++ .../measures/ui/measure_edit_dialog.dart | 69 ++++ .../ui/measure_list_item.dart | 2 +- .../measures/ui/measures_slide_block.dart | 133 +++++++ .../ui/measures_slide_block_item.dart | 76 ++++ lib/pages/payments/payment.dart | 27 +- lib/pages/payments/payments_create.dart | 31 +- lib/pages/splash/splash.dart | 205 ++++++----- lib/pages/templates/out_dated.dart | 4 +- lib/pages/templates/rate_limit.dart | 2 +- lib/redux/actions/account.dart | 43 +-- lib/redux/actions/contacts.dart | 67 +--- lib/redux/actions/jobs.dart | 72 +--- lib/redux/actions/main.dart | 68 +--- lib/redux/actions/measures.dart | 20 ++ lib/redux/actions/settings.dart | 14 + lib/redux/actions/stats.dart | 16 +- lib/redux/epics/account.dart | 88 +++-- lib/redux/epics/contacts.dart | 51 +-- lib/redux/epics/jobs.dart | 22 +- lib/redux/epics/main.dart | 8 +- lib/redux/epics/measures.dart | 85 +++++ lib/redux/epics/settings.dart | 44 +++ lib/redux/epics/stats.dart | 20 +- lib/redux/reducers/account.dart | 35 +- lib/redux/reducers/contacts.dart | 112 +++--- lib/redux/reducers/jobs.dart | 125 +++---- lib/redux/reducers/main.dart | 23 +- lib/redux/reducers/measures.dart | 21 ++ lib/redux/reducers/settings.dart | 26 ++ lib/redux/reducers/stats.dart | 26 +- lib/redux/states/jobs.dart | 4 +- lib/redux/states/main.dart | 12 + lib/redux/states/measures.dart | 48 +++ lib/redux/states/settings.dart | 38 ++ lib/redux/view_models/account.dart | 10 +- lib/redux/view_models/contact_job.dart | 9 +- lib/redux/view_models/contacts.dart | 18 + lib/redux/view_models/jobs.dart | 32 +- lib/redux/view_models/measures.dart | 24 ++ lib/redux/view_models/settings.dart | 22 ++ lib/redux/view_models/stats.dart | 10 +- lib/services/auth.dart | 2 - lib/services/cloud_db.dart | 3 + lib/services/settings.dart | 24 +- lib/ui/app_bar.dart | 3 +- lib/ui/back_button.dart | 8 +- lib/ui/loading_snackbar.dart | 15 +- lib/{pages/jobs => }/ui/slide_down.dart | 39 ++- lib/utils/tm_confirm_dialog.dart | 15 +- lib/utils/tm_group_model_by.dart | 12 + lib/utils/tm_images.dart | 2 + lib/utils/tm_snackbar.dart | 17 +- pubspec.yaml | 2 +- 92 files changed, 2725 insertions(+), 1461 deletions(-) create mode 100644 assets/images/verified.png delete mode 100644 lib/pages/accounts/measures.dart delete mode 100644 lib/pages/accounts/ui/measures_create.dart delete mode 100644 lib/pages/contacts/ui/contact_jobs_list.dart create mode 100644 lib/pages/homepage/ui/review_modal.dart delete mode 100644 lib/pages/jobs/ui/measure_lists.dart delete mode 100644 lib/pages/jobs/ui/measures.dart create mode 100644 lib/pages/measures/measures.dart create mode 100644 lib/pages/measures/measures_create.dart create mode 100644 lib/pages/measures/measures_manage.dart rename lib/pages/{jobs => measures}/ui/measure_create_items.dart (58%) create mode 100644 lib/pages/measures/ui/measure_dialog.dart create mode 100644 lib/pages/measures/ui/measure_edit_dialog.dart rename lib/pages/{jobs => measures}/ui/measure_list_item.dart (96%) create mode 100644 lib/pages/measures/ui/measures_slide_block.dart create mode 100644 lib/pages/measures/ui/measures_slide_block_item.dart create mode 100644 lib/redux/actions/measures.dart create mode 100644 lib/redux/actions/settings.dart create mode 100644 lib/redux/epics/measures.dart create mode 100644 lib/redux/epics/settings.dart create mode 100644 lib/redux/reducers/measures.dart create mode 100644 lib/redux/reducers/settings.dart create mode 100644 lib/redux/states/measures.dart create mode 100644 lib/redux/states/settings.dart create mode 100644 lib/redux/view_models/measures.dart create mode 100644 lib/redux/view_models/settings.dart rename lib/{pages/jobs => }/ui/slide_down.dart (65%) create mode 100644 lib/utils/tm_group_model_by.dart diff --git a/.vscode/settings.json b/.vscode/settings.json index f2b29006..fc628a9c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,4 +6,5 @@ "editor.insertSpaces": false, "editor.formatOnSave": true }, + "java.configuration.updateBuildConfiguration": "interactive", } diff --git a/TASKS.todo b/TASKS.todo index 19867925..276638a3 100644 --- a/TASKS.todo +++ b/TASKS.todo @@ -1,14 +1,34 @@ PENDING: - ☐ delete-able payment + images + jobs + contacts - ☐ implement share payment + image - ☐ Collect account details on account creation - ☐ Generate invoice & share as SMS containing link to pay on paystack - ☐ Firebase function on successful payments w/ push notification + ☐ delete-able payment + images + jobs + contacts @goals + ☐ implement share payment + image @goals + ☐ Collect account details on account creation @goals + ☐ Generate invoice & share as SMS containing link to pay on paystack @goals + ☐ Firebase function on successful payments w/ push notification @goals ☐ On account create, send personal email + email to user @high - ☐ personalize measurements w/ account @critical - ☐ indicator for premium accounts @today + ☐ empty states icons @quickie + ☐ edit job measurements @high + ☐ Trigger loading action for any side-effect to the store @low + ☐ gradual migration to BLoC to reduce boilerplate from Redux @goals + ☐ copy and paste jobs @goals + ☐ migrate all firebase actions to within epics @goals COMPLETED: + ✔ indicator for premium accounts @goals @done(18-07-27 04:38) + ✔ add in-app review @quickie @done(18-07-27 03:16) + ✔ personalize measurements w/ account @critical @done(18-07-25 18:47) + ✔ long press to delete or edit measure block snackbar message @critical @done(18-07-25 01:20) + ✔ fix issue with reloading state on outdated app @critical @done(18-07-24 11:23) + ✔ add WillPopScope to Homepage @high @done(18-07-24 11:22) + ✔ make sure fetching settings happens before silent login @critical @done(18-07-24 08:51) + ✔ adding multiple contact doesn't work @critical @done(18-07-23 14:06) + ✔ snackbar loader width @today @started(18-07-23 01:05) @done(18-07-23 02:01) @lasted(56m27s) + ✔ handle on image picker canceled. @critical @done(18-07-23 01:01) + ✔ No UI for additional note on job @today @done(18-07-23 00:57) + ✔ Add proper keyboard actions @started(18-07-23 00:15) @done(18-07-23 00:48) @lasted(33m35s) + ✔ The getter 'id' was called on null on contact create @critical @done(18-07-23 00:48) + ✔ track amount left on payment creation @today @started(18-07-23 00:01) @done(18-07-23 00:14) @lasted(13m32s) + ✔ DRY redux @today @done(18-07-22 12:34) + ✔ fix create "contacts" typo in modal @today @done(18-07-22 09:23) ✔ reference upload issue @critical @done(18-07-21 16:40) ✔ stats image count on job update image @today @done(18-07-21 15:49) ✔ check for outdated version + template @today @done(18-07-21 15:40) diff --git a/android/app/build.gradle b/android/app/build.gradle index a18879a7..9d8f4e16 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -19,8 +19,8 @@ def keystoreProperties = new Properties() keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) ext.versionMajor = 1 -ext.versionMinor = 0 -ext.versionPatch = 3 +ext.versionMinor = 1 +ext.versionPatch = 0 ext.versionClassifier = "beta" ext.isSnapshot = false ext.minimumSdkVersion = 16 diff --git a/assets/images/verified.png b/assets/images/verified.png new file mode 100644 index 0000000000000000000000000000000000000000..f16091c2589f5eecba4f10e57fe2368720f8bd5b GIT binary patch literal 1107 zcmV-Z1g!gsP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D1L#RaK~!i%?b=(& z6;&9A@h>aOOvxa$$bt}!(kPIi0?}^lNDveeq)0>u5~8BAqJv7bNuqf+=V%YY=tZhI7$cHX-V? z4L6~997&cT>aZ<8V>XKAD7=lRv!-6frTDgTtjY@ES`^8Hh&pM?`C}@~6NuW#4|6ga z$NDJhpsAZ-e5wCI)FycVjbaTIUAQT$0pGi^_Xemh@4ZDnIy1Xq@SYI;gU<=y>i6909*p?++jAjD@E|Z`7uf^bgQ?{0i(rbn)bOTnIA((d6lb zHxb4sn4j}C922kask&kTM=D6XX$+>%prIb9$)JL z;$rD1T`N)mxC(70)1Q-;0&ScofO4s5`{!B{)NviIMa;d3 z&gc9gsg<~-jXJF3Ya9j_T@Dmco1EKFwl5w>sHnp_(!cHkMCUtMgraen>}Q^k?@pTN zK1I}~6XzL6ykcqc1CM3A>G!Ld*87d)EM8{=BH~kYN6|R9L~VL;wOK58!A*onx6O$+ zV0_WL)nl(Ay0*J9)?fpPyCa>Jvh$JBX{9)@lB7-?4DyY<{6#$r)$?*)-pUZ$B+mNP zk?lIjl~fH%1dR>^rBfjn measurements; + Map measurements; int totalJobs; int pendingJobs; @@ -24,24 +23,20 @@ class ContactModel extends Model { this.location, this.imageUrl, DateTime createdAt, - List measurements, + Map measurements, this.totalJobs = 0, this.pendingJobs = 0, }) : id = id ?? uuid(), createdAt = createdAt ?? DateTime.now(), userID = userID ?? Auth.getUser.uid, - measurements = measurements != null && measurements.isNotEmpty - ? measurements - : createDefaultMeasures(); + measurements = + measurements != null && measurements.isNotEmpty ? measurements : {}; factory ContactModel.fromJson(Map json) { assert(json != null); - final List measurements = []; + Map measurements; if (json['measurements'] != null) { - json['measurements'].forEach( - (dynamic measure) => measurements - .add(MeasureModel.fromJson(measure.cast())), - ); + measurements = json['measurements'].cast(); } return new ContactModel( id: json['id'], @@ -66,7 +61,7 @@ class ContactModel extends Model { String phone, String location, String imageUrl, - List measurements, + Map measurements, }) { return new ContactModel( id: this.id, @@ -92,7 +87,7 @@ class ContactModel extends Model { 'location': location, 'imageUrl': imageUrl, 'createdAt': createdAt.toString(), - 'measurements': measurements.map((measure) => measure.toMap()).toList(), + 'measurements': measurements, 'totalJobs': totalJobs, 'pendingJobs': pendingJobs, }; diff --git a/lib/models/job.dart b/lib/models/job.dart index 7b32fcc4..8b490f88 100644 --- a/lib/models/job.dart +++ b/lib/models/job.dart @@ -2,7 +2,6 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter/foundation.dart'; import 'package:tailor_made/models/image.dart'; import 'package:tailor_made/models/main.dart'; -import 'package:tailor_made/models/measure.dart'; import 'package:tailor_made/models/payment.dart'; import 'package:tailor_made/services/auth.dart'; import 'package:tailor_made/utils/tm_uuid.dart'; @@ -17,7 +16,7 @@ class JobModel extends Model { double pendingPayment; String notes; List images; - List measurements; + Map measurements; List payments; bool isComplete; DateTime createdAt; @@ -32,7 +31,7 @@ class JobModel extends Model { this.images, this.completedPayment = 0.0, this.pendingPayment = 0.0, - this.measurements = const [], + this.measurements = const {}, this.payments = const [], this.isComplete = false, DateTime createdAt, @@ -42,12 +41,9 @@ class JobModel extends Model { factory JobModel.fromJson(Map json) { assert(json != null); - final List measurements = []; + Map measurements; if (json['measurements'] != null) { - json['measurements'].forEach( - (dynamic measure) => measurements - .add(MeasureModel.fromJson(measure.cast())), - ); + measurements = json['measurements'].cast(); } final List payments = []; if (json['payments'] != null) { @@ -84,6 +80,20 @@ class JobModel extends Model { return JobModel.fromJson(doc.data)..reference = doc.reference; } + // TODO implement others + JobModel copyWith({ + String contactID, + Map measurements, + }) { + return new JobModel( + id: this.id, + userID: this.userID, + contactID: contactID ?? this.contactID, + measurements: measurements ?? this.measurements, + createdAt: this.createdAt, + )..reference = this.reference; + } + @override Map toMap() { return { @@ -97,7 +107,7 @@ class JobModel extends Model { 'notes': notes, 'images': images.map((image) => image.toMap()).toList(), 'createdAt': createdAt.toString(), - 'measurements': measurements.map((measure) => measure.toMap()).toList(), + 'measurements': measurements, 'payments': payments.map((payment) => payment.toMap()).toList(), 'isComplete': isComplete, }; diff --git a/lib/models/main.dart b/lib/models/main.dart index 1ff2af5f..fb171f7b 100644 --- a/lib/models/main.dart +++ b/lib/models/main.dart @@ -11,4 +11,8 @@ abstract class Model { String toString() { return json.encode(toMap()); } + + dynamic operator [](String key) { + return toMap()[key]; + } } diff --git a/lib/models/measure.dart b/lib/models/measure.dart index 45f495a9..c3213736 100644 --- a/lib/models/measure.dart +++ b/lib/models/measure.dart @@ -1,30 +1,32 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter/foundation.dart'; import 'package:tailor_made/models/main.dart'; +import 'package:tailor_made/utils/tm_uuid.dart'; List createDefaultMeasures() { return [ - MeasureModel(name: 'Arm Hole', type: MeasureModelType.blouse), - MeasureModel(name: 'Shoulder', type: MeasureModelType.blouse), - MeasureModel(name: 'Bust', type: MeasureModelType.blouse), - MeasureModel(name: 'Bust Point', type: MeasureModelType.blouse), - MeasureModel(name: 'Shoulder - Bust Point', type: MeasureModelType.blouse), - MeasureModel(name: 'Shoulder - Under Bust', type: MeasureModelType.blouse), - MeasureModel(name: 'Shoulder - Waist', type: MeasureModelType.blouse), - MeasureModel(name: 'Length', type: MeasureModelType.trouser), - MeasureModel(name: 'Waist', type: MeasureModelType.trouser), - MeasureModel(name: 'Crouch', type: MeasureModelType.trouser), - MeasureModel(name: 'Thigh', type: MeasureModelType.trouser), - MeasureModel(name: 'Body Rise', type: MeasureModelType.trouser), - MeasureModel(name: 'Width', type: MeasureModelType.trouser), - MeasureModel(name: 'Hip', type: MeasureModelType.trouser), - MeasureModel(name: 'Full Length', type: MeasureModelType.skirts), - MeasureModel(name: 'Short Length', type: MeasureModelType.skirts), - MeasureModel(name: 'Knee Length', type: MeasureModelType.skirts), - MeasureModel(name: 'Hip', type: MeasureModelType.skirts), - MeasureModel(name: 'Waist', type: MeasureModelType.gown), - MeasureModel(name: 'Long Length', type: MeasureModelType.gown), - MeasureModel(name: 'Short Length', type: MeasureModelType.gown), - MeasureModel(name: 'Knee Length', type: MeasureModelType.gown), + MeasureModel(name: 'Arm Hole', group: MeasureModelType.blouse), + MeasureModel(name: 'Shoulder', group: MeasureModelType.blouse), + MeasureModel(name: 'Bust', group: MeasureModelType.blouse), + MeasureModel(name: 'Bust Point', group: MeasureModelType.blouse), + MeasureModel(name: 'Shoulder - Bust Point', group: MeasureModelType.blouse), + MeasureModel(name: 'Shoulder - Under Bust', group: MeasureModelType.blouse), + MeasureModel(name: 'Shoulder - Waist', group: MeasureModelType.blouse), + MeasureModel(name: 'Length', group: MeasureModelType.trouser), + MeasureModel(name: 'Waist', group: MeasureModelType.trouser), + MeasureModel(name: 'Crouch', group: MeasureModelType.trouser), + MeasureModel(name: 'Thigh', group: MeasureModelType.trouser), + MeasureModel(name: 'Body Rise', group: MeasureModelType.trouser), + MeasureModel(name: 'Width', group: MeasureModelType.trouser), + MeasureModel(name: 'Hip', group: MeasureModelType.trouser), + MeasureModel(name: 'Full Length', group: MeasureModelType.skirts), + MeasureModel(name: 'Short Length', group: MeasureModelType.skirts), + MeasureModel(name: 'Knee Length', group: MeasureModelType.skirts), + MeasureModel(name: 'Hip', group: MeasureModelType.skirts), + MeasureModel(name: 'Waist', group: MeasureModelType.gown), + MeasureModel(name: 'Long Length', group: MeasureModelType.gown), + MeasureModel(name: 'Short Length', group: MeasureModelType.gown), + MeasureModel(name: 'Knee Length', group: MeasureModelType.gown), ]; } @@ -36,35 +38,47 @@ class MeasureModelType { } class MeasureModel extends Model { + String id; String name; + // TODO only for UI purposes double value; String unit; - String type; + String group; + DateTime createdAt; MeasureModel({ + String id, @required this.name, this.value = 0.0, this.unit = 'In', - @required this.type, - }); + DateTime createdAt, + @required this.group, + }) : id = id ?? uuid(), + createdAt = createdAt ?? DateTime.now(); factory MeasureModel.fromJson(Map json) { assert(json != null); return new MeasureModel( + id: json['id'], name: json['name'], - value: double.tryParse(json['value'].toString()), unit: json['unit'], - type: json['type'], + group: json['group'], + createdAt: DateTime.tryParse(json['createdAt'].toString()), ); } + factory MeasureModel.fromDoc(DocumentSnapshot doc) { + return MeasureModel.fromJson(doc.data)..reference = doc.reference; + } + @override Map toMap() { return { + 'id': id, 'name': name, - 'value': value, 'unit': unit, - 'type': type, + 'group': group, + 'createdAt': createdAt.toString(), }; } } diff --git a/lib/pages/accounts/measures.dart b/lib/pages/accounts/measures.dart deleted file mode 100644 index 275a52cc..00000000 --- a/lib/pages/accounts/measures.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_spinkit/flutter_spinkit.dart'; -import 'package:tailor_made/models/account.dart'; -import 'package:tailor_made/pages/accounts/ui/measures_create.dart'; -import 'package:tailor_made/ui/app_bar.dart'; -import 'package:tailor_made/utils/tm_navigate.dart'; - -class AccountMeasuresPage extends StatelessWidget { - final AccountModel account; - - const AccountMeasuresPage({ - Key key, - @required this.account, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme.subhead; - return Scaffold( - appBar: appBar( - context, - title: "Measurements", - actions: [ - IconButton( - icon: Icon(Icons.add), - onPressed: () => TMNavigate( - context, - MeasuresCreate(), - fullscreenDialog: true, - ), - ) - ], - ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - SpinKitFadingCube( - color: Colors.grey.shade300, - ), - SizedBox(height: 48.0), - Text( - "COMING SOON", - style: textTheme.copyWith( - color: Colors.black87, fontWeight: FontWeight.w700), - textAlign: TextAlign.center, - ), - SizedBox(height: 32.0), - ], - ), - ), - ); - } -} diff --git a/lib/pages/accounts/ui/measures_create.dart b/lib/pages/accounts/ui/measures_create.dart deleted file mode 100644 index 8e6c33bb..00000000 --- a/lib/pages/accounts/ui/measures_create.dart +++ /dev/null @@ -1,149 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:tailor_made/ui/full_button.dart'; -import 'package:tailor_made/utils/tm_snackbar.dart'; -import 'package:tailor_made/utils/tm_theme.dart'; - -class MeasuresCreate extends StatefulWidget { - const MeasuresCreate({ - Key key, - }) : super(key: key); - - @override - MeasuresCreateState createState() => new MeasuresCreateState(); -} - -class MeasuresCreateState extends State with SnackBarProvider { - final GlobalKey _formKey = new GlobalKey(); - bool _autovalidate = false; - String group_name; - final List measures = []; - - @override - final scaffoldKey = new GlobalKey(); - - @override - Widget build(BuildContext context) { - final TMTheme theme = TMTheme.of(context); - final List children = []; - - children.add(makeHeader("Group Name")); - children.add(buildEnterName()); - - children.add( - Padding( - child: FullButton( - child: Text( - "FINISH", - style: TextStyle(color: Colors.white), - ), - // onPressed: _handleSubmit, - onPressed: measures.isEmpty ? null : _handleSubmit, - ), - padding: EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 50.0), - ), - ); - - children.add(SizedBox(height: 32.0)); - - return Scaffold( - key: scaffoldKey, - backgroundColor: theme.scaffoldColor, - appBar: AppBar( - title: Text("Add Group", style: theme.appBarStyle), - brightness: Brightness.light, - centerTitle: false, - elevation: 1.0, - actions: [ - IconButton( - icon: Icon(Icons.add), - onPressed: () {}, - ) - ], - ), - body: Theme( - data: ThemeData( - hintColor: kHintColor, - primaryColor: kPrimaryColor, - ), - child: buildBody(theme, children), - ), - ); - } - - Widget makeHeader(String title, [String trailing = ""]) { - return new Container( - color: Colors.grey[100].withOpacity(.4), - margin: const EdgeInsets.only(top: 8.0), - padding: const EdgeInsets.only( - top: 8.0, - bottom: 8.0, - left: 16.0, - right: 16.0, - ), - alignment: AlignmentDirectional.centerStart, - child: new Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - title.toUpperCase(), - style: ralewayLight(12.0, kTextBaseColor.shade800), - ), - Text(trailing, style: ralewayLight(12.0, kTextBaseColor.shade800)), - ], - ), - ); - } - - Widget buildEnterName() { - return new Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0), - child: new TextFormField( - keyboardType: TextInputType.text, - style: TextStyle(fontSize: 18.0, color: Colors.black), - decoration: new InputDecoration( - isDense: true, - hintText: "Enter Group Name", - hintStyle: TextStyle(fontSize: 14.0), - ), - validator: (value) => (value.isNotEmpty) ? null : "Please input a name", - onSaved: (value) => group_name = value.trim(), - ), - ); - } - - Widget buildBody(TMTheme theme, List children) { - return new SafeArea( - top: false, - child: new SingleChildScrollView( - child: Form( - key: _formKey, - autovalidate: _autovalidate, - child: new Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: children, - ), - ), - ), - ); - } - - void _handleSubmit() async { - final FormState form = _formKey.currentState; - if (form == null) { - return; - } - - if (!form.validate()) { - _autovalidate = true; // Start validating on every change. - showInSnackBar('Please fix the errors in red before submitting.'); - } else { - form.save(); - showLoadingSnackBar(); - try {} catch (e) { - closeLoadingSnackBar(); - showInSnackBar(e.toString()); - } - } - } -} diff --git a/lib/pages/contacts/contact.dart b/lib/pages/contacts/contact.dart index ecf2db90..6432594f 100644 --- a/lib/pages/contacts/contact.dart +++ b/lib/pages/contacts/contact.dart @@ -1,15 +1,12 @@ -import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter/material.dart'; import 'package:flutter_redux/flutter_redux.dart'; import 'package:tailor_made/models/contact.dart'; -import 'package:tailor_made/models/job.dart'; import 'package:tailor_made/pages/contacts/ui/contact_appbar.dart'; import 'package:tailor_made/pages/contacts/ui/contact_gallery_grid.dart'; -import 'package:tailor_made/pages/contacts/ui/contact_jobs_list.dart'; import 'package:tailor_made/pages/contacts/ui/contact_payments_list.dart'; +import 'package:tailor_made/pages/jobs/jobs_list.dart'; import 'package:tailor_made/redux/states/main.dart'; import 'package:tailor_made/redux/view_models/contacts.dart'; -import 'package:tailor_made/services/cloud_db.dart'; import 'package:tailor_made/ui/tm_loading_spinner.dart'; import 'package:tailor_made/utils/tm_theme.dart'; @@ -32,66 +29,60 @@ class _ContactState extends State { Widget build(BuildContext context) { final TMTheme theme = TMTheme.of(context); - // TODO Maybe a clean up here return StoreConnector( converter: (store) => ContactsViewModel(store)..contactID = widget.contact.id, - builder: (BuildContext context, ContactsViewModel vm) { - final contact = vm.selected; + builder: (BuildContext context, vm) { + // in the case of newly created contacts + final contact = vm.selected ?? widget.contact; return new DefaultTabController( - length: 3, + length: TABS.length, child: new Scaffold( backgroundColor: theme.scaffoldColor, appBar: new AppBar( backgroundColor: kAccentColor, automaticallyImplyLeading: false, - title: new ContactAppBar(contact: contact), + title: new ContactAppBar( + contact: contact, + grouped: vm.measuresGrouped, + ), titleSpacing: 0.0, centerTitle: false, brightness: Brightness.dark, bottom: tabTitles(), ), - // TODO could maybe refactor this as well - body: new StreamBuilder( - stream: CloudDb.jobs - .where("contactID", isEqualTo: contact.id) - .snapshots(), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return Center( - child: loadingSpinner(), - ); - } - - final List list = snapshot.data.documents; - - final jobs = - list.map((item) => JobModel.fromDoc(item)).toList(); - - return new TabBarView( - children: [ - tabView( - name: TABS[0].toLowerCase(), - child: JobsListWidget(contact: contact), - ), - tabView( - name: TABS[1].toLowerCase(), - child: GalleryGridWidget(contact: contact, jobs: jobs), - ), - tabView( - name: TABS[2].toLowerCase(), - child: PaymentsListWidget(contact: contact, jobs: jobs), - ), - ], - ); - }, - ), + body: _buildBody(vm, contact), ), ); }, ); } + Widget _buildBody(ContactsViewModel vm, ContactModel contact) { + if (vm.isLoading) { + return Center( + child: loadingSpinner(), + ); + } + + return new TabBarView( + children: [ + tabView( + name: TABS[0].toLowerCase(), + child: JobList(jobs: vm.selectedJobs), + ), + tabView( + name: TABS[1].toLowerCase(), + child: GalleryGridWidget(contact: contact, jobs: vm.selectedJobs), + ), + tabView( + name: TABS[2].toLowerCase(), + child: PaymentsListWidget(contact: contact, jobs: vm.selectedJobs), + ), + ], + ); + } + Widget tabTitles() { return PreferredSize( child: Container( diff --git a/lib/pages/contacts/contacts.dart b/lib/pages/contacts/contacts.dart index d36d7ed4..247d7292 100644 --- a/lib/pages/contacts/contacts.dart +++ b/lib/pages/contacts/contacts.dart @@ -3,7 +3,6 @@ import 'package:flutter_redux/flutter_redux.dart'; import 'package:tailor_made/pages/contacts/contacts_create.dart'; import 'package:tailor_made/pages/contacts/ui/contacts_filter_button.dart'; import 'package:tailor_made/pages/contacts/ui/contacts_list_item.dart'; -import 'package:tailor_made/redux/actions/main.dart'; import 'package:tailor_made/redux/states/main.dart'; import 'package:tailor_made/redux/view_models/contacts.dart'; import 'package:tailor_made/ui/app_bar.dart'; @@ -13,12 +12,7 @@ import 'package:tailor_made/utils/tm_navigate.dart'; import 'package:tailor_made/utils/tm_theme.dart'; class ContactsPage extends StatefulWidget { - // final List contacts; - - const ContactsPage({ - Key key, - // @required this.contacts, - }) : super(key: key); + const ContactsPage({Key key}) : super(key: key); @override _ContactsPageState createState() => new _ContactsPageState(); @@ -33,8 +27,6 @@ class _ContactsPageState extends State { return new StoreConnector( converter: (store) => ContactsViewModel(store), - onInit: (store) => store.dispatch(new InitDataEvents()), - onDispose: (store) => store.dispatch(new DisposeDataEvents()), builder: (BuildContext context, ContactsViewModel vm) { return WillPopScope( child: new Scaffold( diff --git a/lib/pages/contacts/contacts_create.dart b/lib/pages/contacts/contacts_create.dart index a4133011..d9ec7697 100644 --- a/lib/pages/contacts/contacts_create.dart +++ b/lib/pages/contacts/contacts_create.dart @@ -1,13 +1,15 @@ import 'package:contact_picker/contact_picker.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_redux/flutter_redux.dart'; import 'package:tailor_made/models/contact.dart'; import 'package:tailor_made/pages/contacts/contact.dart'; import 'package:tailor_made/pages/contacts/ui/contact_form.dart'; import 'package:tailor_made/pages/contacts/ui/contact_measure.dart'; +import 'package:tailor_made/redux/states/main.dart'; +import 'package:tailor_made/redux/view_models/measures.dart'; import 'package:tailor_made/services/cloud_db.dart'; import 'package:tailor_made/ui/app_bar.dart'; -import 'package:tailor_made/utils/tm_confirm_dialog.dart'; import 'package:tailor_made/utils/tm_navigate.dart'; import 'package:tailor_made/utils/tm_snackbar.dart'; import 'package:tailor_made/utils/tm_theme.dart'; @@ -40,57 +42,83 @@ class _ContactsCreatePageState extends State @override Widget build(BuildContext context) { final TMTheme theme = TMTheme.of(context); - return new Scaffold( - key: scaffoldKey, - backgroundColor: theme.scaffoldColor, - appBar: appBar( - context, - title: "Create Contact", - actions: [ - IconButton( - icon: Icon( - Icons.contacts, - color: kTitleBaseColor, - ), - onPressed: _handleSelectContact, + return StoreConnector( + converter: (store) => MeasuresViewModel(store), + builder: (BuildContext context, vm) { + return new Scaffold( + key: scaffoldKey, + backgroundColor: theme.scaffoldColor, + appBar: appBar( + context, + title: "Create Contact", + actions: [ + IconButton( + icon: Icon( + Icons.contacts, + color: kTitleBaseColor, + ), + onPressed: _handleSelectContact, + ), + IconButton( + icon: Icon( + Icons.content_cut, + color: contact.measurements.isEmpty + ? kAccentColor + : kTitleBaseColor, + ), + onPressed: () => _handleSelectMeasure(vm), + ), + ], ), - IconButton( - icon: Icon( - Icons.content_cut, - color: kTitleBaseColor, - ), - onPressed: () => - TMNavigate(context, ContactMeasure(contact: contact)), + body: ContactForm( + key: _formKey, + contact: contact, + onHandleSubmit: _handleSubmit, + onHandleValidate: _handleValidate, + onHandleUpload: _handleUpload, ), - ], - ), - body: ContactForm( - key: _formKey, - contact: contact, - onHandleSubmit: _handleSubmit, - onHandleValidate: _handleValidate, - ), + ); + }, ); } void _handleSelectContact() async { final _selectedContact = await _contactPicker.selectContact(); - _formKey.currentState.updateContact( - contact.copyWith( - fullname: _selectedContact.fullName, - phone: _selectedContact.phoneNumber.number, - ), - ); + + if (_selectedContact == null) { + return; + } + + _formKey.currentState.updateContact(contact.copyWith( + fullname: _selectedContact.fullName, + phone: _selectedContact.phoneNumber?.number, + )); } - void _handleValidate() async { + void _handleValidate() { showInSnackBar('Please fix the errors in red before submitting.'); } - void _handleSubmit(ContactModel contact) async { + void _handleUpload(String message) { + showInSnackBar(message); + } + + void _handleSubmit(ContactModel _contact) async { + if (contact.measurements.isEmpty) { + showInSnackBar("Leaving Measurements empty? Click on Scissors button."); + return; + } + showLoadingSnackBar(); try { + contact = contact.copyWith( + fullname: _contact.fullname, + phone: _contact.phone, + imageUrl: _contact.imageUrl, + location: _contact.location, + ); + final ref = CloudDb.contactsRef.document(contact.id); await ref.setData(contact.toMap()); @@ -98,24 +126,32 @@ class _ContactsCreatePageState extends State closeLoadingSnackBar(); showInSnackBar("Successfully Added"); - final choice = await confirmDialog( - context: context, - title: Text("Do you wish to add another?"), + Navigator.pushReplacement( + context, + TMNavigate.slideIn( + ContactPage(contact: ContactModel.fromDoc(snap)), + ), ); - - if (choice == false) { - Navigator.pushReplacement( - context, - TMNavigate.slideIn( - ContactPage(contact: ContactModel.fromDoc(snap))), - ); - } else { - _formKey.currentState.updateContact(new ContactModel()); - } }); } catch (e) { closeLoadingSnackBar(); showInSnackBar(e.toString()); } } + + void _handleSelectMeasure(MeasuresViewModel vm) async { + final _contact = await Navigator.push( + context, + TMNavigate.fadeIn(ContactMeasure( + contact: contact, + grouped: vm.grouped, + )), + ); + + setState(() { + contact = contact.copyWith( + measurements: _contact.measurements, + ); + }); + } } diff --git a/lib/pages/contacts/contacts_edit.dart b/lib/pages/contacts/contacts_edit.dart index d62e93c1..41a1c5aa 100644 --- a/lib/pages/contacts/contacts_edit.dart +++ b/lib/pages/contacts/contacts_edit.dart @@ -52,6 +52,7 @@ class _ContactsEditPageState extends State contact: widget.contact, onHandleSubmit: _handleSubmit, onHandleValidate: _handleValidate, + onHandleUpload: _handleUpload, ), ); } @@ -66,10 +67,14 @@ class _ContactsEditPageState extends State ); } - void _handleValidate() async { + void _handleValidate() { showInSnackBar('Please fix the errors in red before submitting.'); } + void _handleUpload(String message) { + showInSnackBar(message); + } + void _handleSubmit(ContactModel contact) async { showLoadingSnackBar(); diff --git a/lib/pages/contacts/ui/contact_appbar.dart b/lib/pages/contacts/ui/contact_appbar.dart index 09f03630..4006199b 100644 --- a/lib/pages/contacts/ui/contact_appbar.dart +++ b/lib/pages/contacts/ui/contact_appbar.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; import 'package:tailor_made/models/contact.dart'; +import 'package:tailor_made/models/measure.dart'; import 'package:tailor_made/pages/contacts/contacts_edit.dart'; import 'package:tailor_made/pages/contacts/ui/contact_measure.dart'; import 'package:tailor_made/pages/jobs/jobs_create.dart'; -import 'package:tailor_made/pages/jobs/ui/measures.dart'; +import 'package:tailor_made/pages/measures/measures.dart'; import 'package:tailor_made/ui/circle_avatar.dart'; import 'package:tailor_made/utils/tm_navigate.dart'; import 'package:tailor_made/utils/tm_phone.dart'; @@ -17,10 +18,12 @@ enum Choice { } class ContactAppBar extends StatefulWidget { + final Map> grouped; final ContactModel contact; const ContactAppBar({ Key key, + @required this.grouped, this.contact, }) : super(key: key); @@ -41,7 +44,10 @@ class ContactAppBarState extends State { case Choice.EditMeasure: return TMNavigate( context, - ContactMeasure(contact: widget.contact), + ContactMeasure( + contact: widget.contact, + grouped: widget.grouped, + ), ); case Choice.EditAccount: return TMNavigate( diff --git a/lib/pages/contacts/ui/contact_form.dart b/lib/pages/contacts/ui/contact_form.dart index 471cf1a6..d0649b45 100644 --- a/lib/pages/contacts/ui/contact_form.dart +++ b/lib/pages/contacts/ui/contact_form.dart @@ -15,6 +15,7 @@ import 'package:tailor_made/utils/tm_validators.dart'; class ContactForm extends StatefulWidget { final void Function(ContactModel) onHandleSubmit; final void Function() onHandleValidate; + final void Function(String) onHandleUpload; final ContactModel contact; const ContactForm({ @@ -22,12 +23,11 @@ class ContactForm extends StatefulWidget { @required this.contact, @required this.onHandleSubmit, @required this.onHandleValidate, + @required this.onHandleUpload, }) : super(key: key); @override - ContactFormState createState() { - return new ContactFormState(); - } + ContactFormState createState() => new ContactFormState(); } class ContactFormState extends State { @@ -36,8 +36,9 @@ class ContactFormState extends State { ContactModel contact; bool _autovalidate = false; StorageReference _lastImgRef; - TextEditingController _fNController; - TextEditingController _pNController; + TextEditingController _fNController, _pNController, _lNController; + final FocusNode _pNFocusNode = new FocusNode(), + _locFocusNode = new FocusNode(); @override void initState() { @@ -45,6 +46,14 @@ class ContactFormState extends State { contact = widget.contact; _fNController = new TextEditingController(text: contact.fullname); _pNController = new TextEditingController(text: contact.phone); + _lNController = new TextEditingController(text: contact.location); + } + + @override + void dispose() { + _pNFocusNode.dispose(); + _locFocusNode.dispose(); + super.dispose(); } @override @@ -78,16 +87,21 @@ class ContactFormState extends State { children: [ TextFormField( controller: _fNController, + textInputAction: TextInputAction.next, decoration: InputDecoration( prefixIcon: Icon(Icons.person), labelText: "Fullname", ), validator: validateAlpha(), onSaved: (fullname) => contact.fullname = fullname.trim(), + onEditingComplete: () => + FocusScope.of(context).requestFocus(_pNFocusNode), ), SizedBox(height: 4.0), TextFormField( + focusNode: _pNFocusNode, controller: _pNController, + textInputAction: TextInputAction.next, keyboardType: TextInputType.phone, decoration: InputDecoration( prefixIcon: Icon(Icons.phone), @@ -96,10 +110,14 @@ class ContactFormState extends State { validator: (value) => (value.isNotEmpty) ? null : "Please input a value", onSaved: (phone) => contact.phone = phone.trim(), + onEditingComplete: () => + FocusScope.of(context).requestFocus(_locFocusNode), ), SizedBox(height: 4.0), TextFormField( - initialValue: contact.location, + focusNode: _locFocusNode, + controller: _lNController, + textInputAction: TextInputAction.done, decoration: InputDecoration( prefixIcon: Icon(Icons.location_city), labelText: "Location", @@ -107,6 +125,7 @@ class ContactFormState extends State { validator: (value) => (value.isNotEmpty) ? null : "Please input a value", onSaved: (location) => contact.location = location.trim(), + onFieldSubmitted: (value) => _handleSubmit(), ), SizedBox(height: 32.0), FullButton( @@ -190,32 +209,42 @@ class ContactFormState extends State { } final imageFile = await ImagePicker.pickImage( source: source, maxWidth: 200.0, maxHeight: 200.0); + if (imageFile == null) { + return; + } final ref = CloudStorage.createContactImage(); final uploadTask = ref.putFile(imageFile); setState(() => isLoading = true); try { contact.imageUrl = (await uploadTask.future).downloadUrl?.toString(); - setState(() { - if (_lastImgRef != null) { - _lastImgRef.delete(); - } - isLoading = false; - _lastImgRef = ref; - }); + if (mounted) { + widget.onHandleUpload("Upload Successful"); + setState(() { + if (_lastImgRef != null) { + _lastImgRef.delete(); + } + isLoading = false; + _lastImgRef = ref; + }); + } } catch (e) { - setState(() => isLoading = false); + if (mounted) { + widget.onHandleUpload("Please try again"); + setState(() => isLoading = false); + } } } void reset() => _formKey.currentState.reset(); void updateContact(ContactModel _contact) { - reset(); setState(() { + reset(); contact = _contact; _fNController.text = contact.fullname ?? ""; _pNController.text = contact.phone ?? ""; + _lNController.text = contact.location ?? ""; }); } } diff --git a/lib/pages/contacts/ui/contact_jobs_list.dart b/lib/pages/contacts/ui/contact_jobs_list.dart deleted file mode 100644 index e93b60c2..00000000 --- a/lib/pages/contacts/ui/contact_jobs_list.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_redux/flutter_redux.dart'; -import 'package:tailor_made/models/contact.dart'; -import 'package:tailor_made/pages/jobs/jobs_list.dart'; -import 'package:tailor_made/redux/states/main.dart'; -import 'package:tailor_made/redux/view_models/jobs.dart'; -import 'package:tailor_made/ui/tm_loading_spinner.dart'; - -class JobsListWidget extends StatelessWidget { - final ContactModel contact; - - const JobsListWidget({ - Key key, - @required this.contact, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return new StoreConnector( - converter: (store) => JobsViewModel(store)..contact = contact, - builder: (BuildContext context, JobsViewModel vm) { - if (vm.isLoading) { - return SliverFillRemaining( - child: loadingSpinner(), - ); - } - return JobList(jobs: vm.jobs); - }, - ); - } -} diff --git a/lib/pages/contacts/ui/contact_measure.dart b/lib/pages/contacts/ui/contact_measure.dart index 520525b2..71ff7ff4 100644 --- a/lib/pages/contacts/ui/contact_measure.dart +++ b/lib/pages/contacts/ui/contact_measure.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; import 'package:tailor_made/models/contact.dart'; -import 'package:tailor_made/pages/jobs/ui/measure_create_items.dart'; -import 'package:tailor_made/pages/jobs/ui/measures.dart'; +import 'package:tailor_made/models/measure.dart'; +import 'package:tailor_made/pages/measures/measures.dart'; +import 'package:tailor_made/pages/measures/ui/measure_create_items.dart'; import 'package:tailor_made/ui/app_bar.dart'; import 'package:tailor_made/ui/full_button.dart'; import 'package:tailor_made/utils/tm_navigate.dart'; @@ -9,10 +10,12 @@ import 'package:tailor_made/utils/tm_snackbar.dart'; import 'package:tailor_made/utils/tm_theme.dart'; class ContactMeasure extends StatefulWidget { + final Map> grouped; final ContactModel contact; const ContactMeasure({ Key key, + @required this.grouped, @required this.contact, }) : super(key: key); @@ -23,17 +26,27 @@ class ContactMeasure extends StatefulWidget { class _ContactMeasureState extends State with SnackBarProvider { final GlobalKey _formKey = new GlobalKey(); bool _autovalidate = false; + ContactModel contact; @override final scaffoldKey = new GlobalKey(); + @override + void initState() { + super.initState(); + contact = widget.contact.copyWith(); + } + @override Widget build(BuildContext context) { final TMTheme theme = TMTheme.of(context); final List children = []; children.add(makeHeader("Measurements", "Inches (In)")); - children.add(MeasureCreateItems(widget.contact.measurements)); + children.add(MeasureCreateItems( + grouped: widget.grouped, + measurements: contact.measurements, + )); children.add( Padding( @@ -56,6 +69,9 @@ class _ContactMeasureState extends State with SnackBarProvider { appBar: appBar( context, title: "Measurements", + onPop: contact?.reference != null + ? null + : () => Navigator.pop(context, contact), actions: [ IconButton( icon: Icon( @@ -64,7 +80,9 @@ class _ContactMeasureState extends State with SnackBarProvider { ), onPressed: () => TMNavigate( context, - MeasuresPage(measurements: widget.contact.measurements), + MeasuresPage( + measurements: contact.measurements, + ), fullscreenDialog: true, ), ) @@ -118,9 +136,10 @@ class _ContactMeasureState extends State with SnackBarProvider { showLoadingSnackBar(); try { + // TODO find a way to remove this from here // During contact creation - if (widget.contact.reference != null) { - await widget.contact.reference.updateData(widget.contact.toMap()); + if (contact.reference != null) { + await contact.reference.updateData(contact.toMap()); } closeLoadingSnackBar(); showInSnackBar("Successfully Updated"); diff --git a/lib/pages/contacts/ui/contacts_filter_button.dart b/lib/pages/contacts/ui/contacts_filter_button.dart index c36ffc9c..cfa07ad2 100644 --- a/lib/pages/contacts/ui/contacts_filter_button.dart +++ b/lib/pages/contacts/ui/contacts_filter_button.dart @@ -34,6 +34,10 @@ class ContactsFilterButton extends StatelessWidget { "Sort by Name", SortType.name, ), + buildTextOption( + "Sort by Completed", + SortType.completed, + ), buildTextOption( "Sort by Pending", SortType.pending, diff --git a/lib/pages/contacts/ui/contacts_list_item.dart b/lib/pages/contacts/ui/contacts_list_item.dart index e7534658..808a3d56 100644 --- a/lib/pages/contacts/ui/contacts_list_item.dart +++ b/lib/pages/contacts/ui/contacts_list_item.dart @@ -22,87 +22,81 @@ class ContactsListItem extends StatelessWidget { Widget build(BuildContext context) { final TMTheme theme = TMTheme.of(context); - Widget iconCircle(IconData icon, VoidCallback onTap) { - return IconButton( - icon: new Icon(icon, size: 20.0), - onPressed: onTap, - ); - } - - Hero avatar() { - return new Hero( - tag: contact.id, - child: new CircleAvatar( - radius: 24.0, - backgroundColor: theme.primaryColor, - backgroundImage: contact.imageUrl != null - ? CachedNetworkImageProvider(contact.imageUrl) - : null, - child: Stack( - children: [ - new Align( - alignment: Alignment(1.05, -1.05), - child: contact.pendingJobs > 0 - ? new Container( - width: 15.5, - height: 15.5, - decoration: new BoxDecoration( - color: theme.accentColor, - border: Border.all( - color: Colors.white, - style: BorderStyle.solid, - width: 2.5, - ), - shape: BoxShape.circle, - ), - ) - : null, - ), - contact.imageUrl != null - ? SizedBox() - : Center( - child: Icon( - Icons.person_outline, - color: Colors.white, - )), - ], - ), - ), - ); - } - - final Row icons = new Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - iconCircle(Icons.call, () => call(contact.phone)), - iconCircle(Icons.message, () => sms(contact.phone)), - ], - ); - - final Text title = new Text( - contact.fullname, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 16.0, color: theme.textColor, fontWeight: FontWeight.w600), - ); - final int pending = contact.pendingJobs; return ListTile( dense: true, - contentPadding: EdgeInsets.only(left: 16.0), onTap: onTapContact ?? () => TMNavigate(context, ContactPage(contact: contact)), - leading: avatar(), - title: title, + leading: avatar(theme), + title: new Text( + contact.fullname, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16.0, + color: theme.textColor, + fontWeight: FontWeight.w600, + ), + ), subtitle: Text( - pending >= 1 - ? "$pending pending" - : "${contact.totalJobs > 0 ? contact.totalJobs : 'none'} completed", - style: TextStyle(fontSize: 14.0, color: kTextBaseColor)), - trailing: showActions ? icons : null, + pending >= 1 + ? "$pending pending" + : "${contact.totalJobs > 0 ? contact.totalJobs : 'none'} completed", + style: TextStyle(fontSize: 14.0, color: kTextBaseColor), + ), + trailing: showActions + ? iconCircle(Icons.call, () => call(contact.phone)) + : null, + ); + } + + Widget iconCircle(IconData icon, VoidCallback onTap) { + return IconButton( + icon: new Icon(icon, size: 22.0), + onPressed: onTap, + ); + } + + Hero avatar(TMTheme theme) { + return new Hero( + tag: contact.id, + child: new CircleAvatar( + radius: 24.0, + backgroundColor: theme.primaryColor, + backgroundImage: contact.imageUrl != null + ? CachedNetworkImageProvider(contact.imageUrl) + : null, + child: Stack( + children: [ + new Align( + alignment: Alignment(1.05, -1.05), + child: contact.pendingJobs > 0 + ? new Container( + width: 15.5, + height: 15.5, + decoration: new BoxDecoration( + color: theme.accentColor, + border: Border.all( + color: Colors.white, + style: BorderStyle.solid, + width: 2.5, + ), + shape: BoxShape.circle, + ), + ) + : null, + ), + contact.imageUrl != null + ? SizedBox() + : Center( + child: Icon( + Icons.person_outline, + color: Colors.white, + )), + ], + ), + ), ); } } diff --git a/lib/pages/gallery/gallery_view.dart b/lib/pages/gallery/gallery_view.dart index ae375e72..d0a8fbf4 100644 --- a/lib/pages/gallery/gallery_view.dart +++ b/lib/pages/gallery/gallery_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_redux/flutter_redux.dart'; import 'package:photo_view/photo_view.dart'; +import 'package:tailor_made/models/account.dart'; import 'package:tailor_made/models/contact.dart'; import 'package:tailor_made/models/image.dart'; import 'package:tailor_made/models/job.dart'; @@ -27,11 +28,13 @@ class GalleryView extends StatelessWidget { ..contactID = image.contactID ..jobID = image.jobID, builder: (BuildContext context, ContactJobViewModel vm) { - final contact = vm.selectedContact; - final job = vm.selectedJob; return new Scaffold( backgroundColor: Colors.black87, - appBar: MyAppBar(contact: contact, job: job), + appBar: MyAppBar( + contact: vm.selectedContact, + job: vm.selectedJob, + account: vm.account, + ), body: new Hero( tag: image.src, child: new PhotoView( @@ -48,11 +51,13 @@ class GalleryView extends StatelessWidget { class MyAppBar extends StatelessWidget implements PreferredSizeWidget { final ContactModel contact; final JobModel job; + final AccountModel account; const MyAppBar({ Key key, this.contact, this.job, + @required this.account, }) : super(key: key); @override @@ -90,14 +95,16 @@ class MyAppBar extends StatelessWidget implements PreferredSizeWidget { TMNavigate(context, ContactPage(contact: contact)), ) : SizedBox(), - IconButton( - icon: Icon( - Icons.share, - color: Colors.white, - ), - // TODO - onPressed: null, - ), + account.hasPremiumEnabled + ? IconButton( + icon: Icon( + Icons.share, + color: Colors.white, + ), + // TODO + onPressed: null, + ) + : SizedBox(), ], ), ), diff --git a/lib/pages/homepage/home_view_model.dart b/lib/pages/homepage/home_view_model.dart index dd3cbb5e..44c7bca2 100644 --- a/lib/pages/homepage/home_view_model.dart +++ b/lib/pages/homepage/home_view_model.dart @@ -8,11 +8,15 @@ import 'package:tailor_made/redux/states/contacts.dart'; import 'package:tailor_made/redux/states/main.dart'; import 'package:tailor_made/redux/states/stats.dart'; import 'package:tailor_made/redux/view_models/stats.dart'; +import 'package:tailor_made/services/settings.dart'; +import 'package:version/version.dart'; class HomeViewModel extends StatsViewModel { HomeViewModel(Store store) : super(store); - AccountModel get account => store.state.account.account; + AccountState get _state => store.state.account; + + AccountModel get account => _state.account; List get contacts => store.state.contacts.contacts; @@ -20,10 +24,10 @@ class HomeViewModel extends StatsViewModel { bool get isLoading { return store.state.stats.status == StatsStatus.loading || store.state.contacts.status == ContactsStatus.loading || - store.state.account.status == AccountStatus.loading; + _state.status == AccountStatus.loading; } - bool get hasSkipedPremium => store.state.account.hasSkipedPremium == true; + bool get hasSkipedPremium => _state.hasSkipedPremium == true; bool get isDisabled => account.status == AccountModelStatus.disabled; @@ -31,8 +35,26 @@ class HomeViewModel extends StatsViewModel { bool get isPending => account.status == AccountModelStatus.pending; + bool get shouldSendRating { + return !account.hasSendRating && + (((store.state.contacts.contacts?.length ?? 0) >= 10) || + ((store.state.jobs.jobs?.length ?? 0) >= 10)); + } + + bool get isOutdated { + final currentVersion = Version.parse(Settings.getVersion()); + final latestVersion = + Version.parse(store.state.settings.settings?.versionName ?? "1.0.0"); + + return latestVersion > currentVersion; + } + void onPremiumSignUp() => store.dispatch(OnPremiumSignUp(payload: account)); + void onSendRating(int rating) => store.dispatch( + OnSendRating(payload: account, rating: rating), + ); + void onReadNotice() => store.dispatch(OnReadNotice(payload: account)); void onSkipedPremium() => store.dispatch(OnSkipedPremium()); diff --git a/lib/pages/homepage/homepage.dart b/lib/pages/homepage/homepage.dart index 23c0166d..cd871b8a 100644 --- a/lib/pages/homepage/homepage.dart +++ b/lib/pages/homepage/homepage.dart @@ -14,8 +14,8 @@ import 'package:tailor_made/pages/templates/rate_limit.dart'; import 'package:tailor_made/redux/actions/main.dart'; import 'package:tailor_made/redux/states/main.dart'; import 'package:tailor_made/services/auth.dart'; -import 'package:tailor_made/services/settings.dart'; import 'package:tailor_made/ui/tm_loading_spinner.dart'; +import 'package:tailor_made/utils/tm_confirm_dialog.dart'; import 'package:tailor_made/utils/tm_images.dart'; import 'package:tailor_made/utils/tm_navigate.dart'; import 'package:tailor_made/utils/tm_phone.dart'; @@ -32,67 +32,75 @@ class HomePage extends StatelessWidget { // without this, i can not replace this route with SplashPage on logout final _context = context; - return new Scaffold( - backgroundColor: theme.scaffoldColor, - resizeToAvoidBottomPadding: false, - body: Stack( - fit: StackFit.expand, - children: [ - Opacity( - opacity: .5, - child: DecoratedBox( - decoration: BoxDecoration( - image: DecorationImage( - image: TMImages.pattern, - fit: BoxFit.cover, + return WillPopScope( + onWillPop: () async { + return await confirmDialog( + context: _context, + content: Text("Continue with Exit?"), + ); + }, + child: new Scaffold( + backgroundColor: theme.scaffoldColor, + resizeToAvoidBottomPadding: false, + body: Stack( + fit: StackFit.expand, + children: [ + Opacity( + opacity: .5, + child: DecoratedBox( + decoration: BoxDecoration( + image: DecorationImage( + image: TMImages.pattern, + fit: BoxFit.cover, + ), ), ), ), - ), - new StoreConnector( - converter: (store) => HomeViewModel(store), - onInit: (store) => store.dispatch(new InitDataEvents()), - onDispose: (store) => store.dispatch(new DisposeDataEvents()), - builder: (context, vm) { - if (vm.isLoading) { - return Center( - child: loadingSpinner(), - ); - } + new StoreConnector( + converter: (store) => HomeViewModel(store), + onInit: (store) => store.dispatch(new InitDataEvents()), + onDispose: (store) => store.dispatch(new DisposeDataEvents()), + builder: (context, vm) { + if (vm.isLoading) { + return Center( + child: loadingSpinner(), + ); + } - if (Settings.isOutdated) { - return OutDatedPage( - onUpdate: () { - open( - 'https://play.google.com/store/apps/details?id=io.github.jogboms.tailormade'); - }, - ); - } + if (vm.isOutdated) { + return OutDatedPage( + onUpdate: () { + open( + 'https://play.google.com/store/apps/details?id=io.github.jogboms.tailormade'); + }, + ); + } - if (vm.isDisabled) { - return AccessDeniedPage( - onSendMail: () { - email( - 'Unwarranted%20Account%20Suspension%20%23${vm.account.uid}'); - }, - ); - } + if (vm.isDisabled) { + return AccessDeniedPage( + onSendMail: () { + email( + 'Unwarranted%20Account%20Suspension%20%23${vm.account.uid}'); + }, + ); + } - if (vm.isWarning && vm.hasSkipedPremium == false) { - return RateLimitPage( - onSignUp: () { - vm.onPremiumSignUp(); - }, - onSkipedPremium: () { - vm.onSkipedPremium(); - }, - ); - } + if (vm.isWarning && vm.hasSkipedPremium == false) { + return RateLimitPage( + onSignUp: () { + vm.onPremiumSignUp(); + }, + onSkipedPremium: () { + vm.onSkipedPremium(); + }, + ); + } - return _buildBody(_context, vm); - }, - ), - ], + return _buildBody(_context, vm); + }, + ), + ], + ), ), ); } diff --git a/lib/pages/homepage/ui/create_button.dart b/lib/pages/homepage/ui/create_button.dart index aa658fcf..ec62c85c 100644 --- a/lib/pages/homepage/ui/create_button.dart +++ b/lib/pages/homepage/ui/create_button.dart @@ -91,7 +91,7 @@ class CreateButtonState extends State child: TMListTile( color: Colors.orangeAccent, icon: Icons.supervisor_account, - title: "Contacts", + title: "Contact", ), ), new SimpleDialogOption( diff --git a/lib/pages/homepage/ui/review_modal.dart b/lib/pages/homepage/ui/review_modal.dart new file mode 100644 index 00000000..a128a582 --- /dev/null +++ b/lib/pages/homepage/ui/review_modal.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +class ReviewModal extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Center( + child: new Material( + animationDuration: Duration(seconds: 5), + borderRadius: BorderRadius.circular(4.0), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 32.0, horizontal: 16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Can you rate the experience so far?", + textAlign: TextAlign.center, + ), + SizedBox(height: 24.0), + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + color: Colors.red, + iconSize: 30.0, + icon: Icon(Icons.sentiment_very_dissatisfied), + onPressed: () => Navigator.pop(context, 1), + ), + SizedBox(width: 4.0), + IconButton( + color: Colors.orange, + iconSize: 30.0, + icon: Icon(Icons.sentiment_dissatisfied), + onPressed: () => Navigator.pop(context, 2), + ), + SizedBox(width: 4.0), + IconButton( + color: Colors.blueGrey, + iconSize: 30.0, + icon: Icon(Icons.sentiment_neutral), + onPressed: () => Navigator.pop(context, 3), + ), + SizedBox(width: 4.0), + IconButton( + color: Colors.lightGreen, + iconSize: 30.0, + icon: Icon(Icons.sentiment_satisfied), + onPressed: () => Navigator.pop(context, 4), + ), + SizedBox(width: 4.0), + IconButton( + color: Colors.green, + iconSize: 30.0, + icon: Icon(Icons.sentiment_very_satisfied), + onPressed: () => Navigator.pop(context, 5), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/homepage/ui/top_button_bar.dart b/lib/pages/homepage/ui/top_button_bar.dart index d9d2dfa8..9856ad8b 100644 --- a/lib/pages/homepage/ui/top_button_bar.dart +++ b/lib/pages/homepage/ui/top_button_bar.dart @@ -1,12 +1,14 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:tailor_made/pages/accounts/measures.dart'; import 'package:tailor_made/pages/homepage/home_view_model.dart'; import 'package:tailor_made/pages/homepage/ui/helpers.dart'; import 'package:tailor_made/pages/homepage/ui/notice_dialog.dart'; +import 'package:tailor_made/pages/homepage/ui/review_modal.dart'; import 'package:tailor_made/pages/homepage/ui/store_name_dialog.dart'; +import 'package:tailor_made/pages/measures/measures_manage.dart'; import 'package:tailor_made/utils/tm_child_dialog.dart'; import 'package:tailor_made/utils/tm_confirm_dialog.dart'; +import 'package:tailor_made/utils/tm_images.dart'; import 'package:tailor_made/utils/tm_navigate.dart'; import 'package:tailor_made/utils/tm_theme.dart'; @@ -61,10 +63,21 @@ class TopButtonBar extends StatelessWidget { color: theme.appBarColor, ), new Align( - alignment: Alignment(1.25, 1.25), - child: account?.hasReadNotice ?? false - ? null - : new Container( + alignment: Alignment(0.0, 2.25), + child: vm.account.hasPremiumEnabled + ? ImageIcon( + TMImages.verified, + color: kPrimaryColor, + ) + : null, + ), + new Align( + alignment: Alignment( + 1.25, + vm.account.hasPremiumEnabled ? -1.25 : 1.25, + ), + child: _shouldShowIndicator(vm) + ? new Container( width: 15.5, height: 15.5, decoration: new BoxDecoration( @@ -76,7 +89,8 @@ class TopButtonBar extends StatelessWidget { ), shape: BoxShape.circle, ), - ), + ) + : null, ), ], ), @@ -88,10 +102,26 @@ class TopButtonBar extends StatelessWidget { ); } + bool _shouldShowIndicator(HomeViewModel vm) { + return !(vm.account?.hasReadNotice ?? false) || vm.shouldSendRating; + } + void Function() _onTapAccount(BuildContext context, HomeViewModel vm) { final account = vm.account; return () async { - if (!(account?.hasReadNotice ?? false)) { + if (vm.shouldSendRating) { + final _res = await showChildDialog( + context: context, + child: ReviewModal(), + ); + + if (_res != null) { + vm.onSendRating(_res); + } + return; + } + + if (_shouldShowIndicator(vm)) { await showChildDialog( context: context, child: NoticeDialog( @@ -170,12 +200,12 @@ class TopButtonBar extends StatelessWidget { break; case AccountOptions.measurement: - TMNavigate(context, AccountMeasuresPage(account: account)); + TMNavigate(context, MeasuresManagePage(account: account)); break; case AccountOptions.logout: final response = await confirmDialog( - context: context, title: Text("You are about to logout.")); + context: context, content: Text("You are about to logout.")); if (response == true) { onLogout(); diff --git a/lib/pages/jobs/job.dart b/lib/pages/jobs/job.dart index 0c423dd9..709aee56 100644 --- a/lib/pages/jobs/job.dart +++ b/lib/pages/jobs/job.dart @@ -2,13 +2,14 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_redux/flutter_redux.dart'; +import 'package:tailor_made/models/contact.dart'; import 'package:tailor_made/models/job.dart'; import 'package:tailor_made/pages/contacts/contact.dart'; import 'package:tailor_made/pages/jobs/ui/gallery_grids.dart'; -import 'package:tailor_made/pages/jobs/ui/measure_lists.dart'; import 'package:tailor_made/pages/jobs/ui/payment_grids.dart'; +import 'package:tailor_made/pages/measures/measures.dart'; import 'package:tailor_made/redux/states/main.dart'; -import 'package:tailor_made/redux/view_models/contacts.dart'; +import 'package:tailor_made/redux/view_models/jobs.dart'; import 'package:tailor_made/ui/avatar_app_bar.dart'; import 'package:tailor_made/ui/tm_loading_spinner.dart'; import 'package:tailor_made/utils/tm_confirm_dialog.dart'; @@ -48,15 +49,16 @@ class JobPageState extends State with SnackBarProvider { Widget build(BuildContext context) { final TMTheme theme = TMTheme.of(context); - return new StreamBuilder( - stream: job.reference.snapshots(), - builder: (BuildContext context, snapshot) { - if (!snapshot.hasData) { + return StoreConnector( + converter: (store) => JobsViewModel(store)..jobID = widget.job.id, + builder: (BuildContext context, vm) { + // in the case of newly created jobs + job = vm.selected ?? widget.job; + if (vm.isLoading) { return Center( child: loadingSpinner(), ); } - job = JobModel.fromDoc(snapshot.data); return new Scaffold( key: scaffoldKey, backgroundColor: theme.scaffoldColor, @@ -75,7 +77,7 @@ class JobPageState extends State with SnackBarProvider { centerTitle: false, backgroundColor: Colors.white, // backgroundColor: Colors.grey.shade300, - title: buildAvatarAppBar(context), + title: buildAvatarAppBar(context, vm.selectedContact), ), ]; }, @@ -86,12 +88,20 @@ class JobPageState extends State with SnackBarProvider { mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ - MeasureLists(measurements: job.measurements), const SizedBox(height: 4.0), GalleryGrids(job: job), const SizedBox(height: 4.0), PaymentGrids(job: job), const SizedBox(height: 32.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + job.notes, + style: ralewayLight(14.0, Colors.black87), + textAlign: TextAlign.justify, + ), + ), + const SizedBox(height: 48.0), ], ), ), @@ -147,120 +157,131 @@ class JobPageState extends State with SnackBarProvider { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Expanded( - child: new Container( - decoration: BoxDecoration( - border: Border(right: TMBorderSide()), - ), - child: new Column( - children: [ - Text( - "PAID", - style: ralewayRegular(8.0), - textAlign: TextAlign.center, - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.arrow_drop_up, - color: Colors.green.shade600, size: 16.0), - const SizedBox(width: 4.0), - Text( - formatNaira(job.completedPayment), - style: - ralewayRegular(18.0, Colors.black87).copyWith( - letterSpacing: 1.25, - ), - textAlign: TextAlign.center, - ), - ], - ), - ], - ), - ), - ), - Expanded( - child: new Column( - children: [ - Text( - "UNPAID", - style: ralewayRegular(8.0), - textAlign: TextAlign.center, - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.arrow_drop_down, - color: Colors.red.shade600, size: 16.0), - const SizedBox(width: 4.0), - Text( - formatNaira(job.pendingPayment), - style: ralewayRegular(18.0, Colors.black87).copyWith( - letterSpacing: 1.25, - ), - textAlign: TextAlign.center, - ), - ], - ), - ], + _buildPaidBox(), + _buildUnpaidBox(), + ], + ), + ), + ], + ); + } + + Expanded _buildUnpaidBox() { + return Expanded( + child: new Column( + children: [ + Text( + "UNPAID", + style: ralewayRegular(8.0), + textAlign: TextAlign.center, + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.arrow_drop_down, + color: Colors.red.shade600, size: 16.0), + const SizedBox(width: 4.0), + Text( + formatNaira(job.pendingPayment), + style: ralewayRegular(18.0, Colors.black87).copyWith( + letterSpacing: 1.25, ), + textAlign: TextAlign.center, ), ], ), + ], + ), + ); + } + + Expanded _buildPaidBox() { + return Expanded( + child: new Container( + decoration: BoxDecoration( + border: Border(right: TMBorderSide()), ), - ], + child: new Column( + children: [ + Text( + "PAID", + style: ralewayRegular(8.0), + textAlign: TextAlign.center, + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.arrow_drop_up, + color: Colors.green.shade600, size: 16.0), + const SizedBox(width: 4.0), + Text( + formatNaira(job.completedPayment), + style: ralewayRegular(18.0, Colors.black87).copyWith( + letterSpacing: 1.25, + ), + textAlign: TextAlign.center, + ), + ], + ), + ], + ), + ), ); } - Widget buildAvatarAppBar(BuildContext context) { + Widget buildAvatarAppBar(BuildContext context, ContactModel contact) { // final textColor = Colors.white; final textColor = Colors.grey.shade800; final date = formatDate(job.createdAt); - return new StoreConnector( - converter: (store) => ContactsViewModel(store)..contactID = job.contactID, - builder: (BuildContext context, ContactsViewModel vm) { - final contact = vm.selected; - return AvatarAppBar( - tag: contact.createdAt.toString(), - imageUrl: contact.imageUrl, - title: new GestureDetector( - onTap: () => TMNavigate(context, ContactPage(contact: contact)), - child: new Text( - contact.fullname, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: ralewayBold(18.0, kTitleBaseColor), - ), - ), - iconColor: textColor, - subtitle: new Text( - date, - style: new TextStyle( - color: textColor, - fontSize: 12.0, - fontWeight: FontWeight.w300, - ), + return AvatarAppBar( + tag: contact.createdAt.toString(), + imageUrl: contact.imageUrl, + title: new GestureDetector( + onTap: () => TMNavigate(context, ContactPage(contact: contact)), + child: new Text( + contact.fullname, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ralewayBold(18.0, kTitleBaseColor), + ), + ), + iconColor: textColor, + subtitle: new Text( + date, + style: new TextStyle( + color: textColor, + fontSize: 12.0, + fontWeight: FontWeight.w300, + ), + ), + actions: [ + IconButton( + icon: new Icon( + Icons.content_cut, ), - actions: [ - IconButton( - icon: new Icon( - Icons.check, - color: job.isComplete ? kPrimaryColor : kTextBaseColor.shade400, + onPressed: () => TMNavigate( + context, + MeasuresPage(measurements: job.measurements), + fullscreenDialog: true, ), - onPressed: null, - ) - ], - ); - }, + ), + IconButton( + icon: new Icon( + Icons.check, + color: job.isComplete ? kPrimaryColor : kTextBaseColor.shade400, + ), + onPressed: null, + ), + ], ); } void onTapComplete() async { final choice = await confirmDialog( context: context, - title: Text("Are you sure?"), + content: Text("Are you sure?"), ); if (choice == null || choice == false) { return; diff --git a/lib/pages/jobs/jobs.dart b/lib/pages/jobs/jobs.dart index c910c255..dea90530 100644 --- a/lib/pages/jobs/jobs.dart +++ b/lib/pages/jobs/jobs.dart @@ -3,7 +3,6 @@ import 'package:flutter_redux/flutter_redux.dart'; import 'package:tailor_made/pages/jobs/jobs_create.dart'; import 'package:tailor_made/pages/jobs/jobs_list.dart'; import 'package:tailor_made/pages/jobs/ui/jobs_filter_button.dart'; -import 'package:tailor_made/redux/actions/main.dart'; import 'package:tailor_made/redux/states/main.dart'; import 'package:tailor_made/redux/view_models/jobs.dart'; import 'package:tailor_made/ui/app_bar.dart'; @@ -25,8 +24,6 @@ class JobsPageState extends State { return new StoreConnector( converter: (store) => JobsViewModel(store), - onInit: (store) => store.dispatch(new InitDataEvents()), - onDispose: (store) => store.dispatch(new DisposeDataEvents()), builder: (BuildContext context, JobsViewModel vm) { return WillPopScope( child: new Scaffold( diff --git a/lib/pages/jobs/jobs_create.dart b/lib/pages/jobs/jobs_create.dart index 7abc6392..e32dcfb8 100644 --- a/lib/pages/jobs/jobs_create.dart +++ b/lib/pages/jobs/jobs_create.dart @@ -4,15 +4,17 @@ import 'package:firebase_storage/firebase_storage.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_masked_text/flutter_masked_text.dart'; +import 'package:flutter_redux/flutter_redux.dart'; import 'package:image_picker/image_picker.dart'; import 'package:tailor_made/models/contact.dart'; import 'package:tailor_made/models/image.dart'; import 'package:tailor_made/models/job.dart'; -import 'package:tailor_made/models/measure.dart'; import 'package:tailor_made/pages/jobs/job.dart'; import 'package:tailor_made/pages/jobs/ui/contact_lists.dart'; import 'package:tailor_made/pages/jobs/ui/gallery_grid_item.dart'; -import 'package:tailor_made/pages/jobs/ui/measure_create_items.dart'; +import 'package:tailor_made/pages/measures/ui/measure_create_items.dart'; +import 'package:tailor_made/redux/states/main.dart'; +import 'package:tailor_made/redux/view_models/measures.dart'; import 'package:tailor_made/services/cloud_db.dart'; import 'package:tailor_made/services/cloud_storage.dart'; import 'package:tailor_made/ui/app_bar.dart'; @@ -56,6 +58,8 @@ class _JobsCreatePageState extends State with SnackBarProvider { decimalSeparator: '.', thousandSeparator: ',', ); + final FocusNode _amountFocusNode = new FocusNode(); + final FocusNode _additionFocusNode = new FocusNode(); bool _autovalidate = false; @@ -68,10 +72,17 @@ class _JobsCreatePageState extends State with SnackBarProvider { contact = widget.contact; job = new JobModel( contactID: contact?.id, - measurements: contact?.measurements ?? createDefaultMeasures(), + measurements: contact?.measurements ?? {}, ); } + @override + void dispose() { + _amountFocusNode.dispose(); + _additionFocusNode.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final TMTheme theme = TMTheme.of(context); @@ -107,7 +118,7 @@ class _JobsCreatePageState extends State with SnackBarProvider { children.add(buildImageGrid()); children.add(makeHeader("Measurements", "Inches (In)")); - children.add(MeasureCreateItems(job.measurements)); + children.add(buildCreateMeasure()); children.add(makeHeader("Additional Notes")); children.add(buildAdditional()); @@ -142,6 +153,18 @@ class _JobsCreatePageState extends State with SnackBarProvider { ); } + Widget buildCreateMeasure() { + return StoreConnector( + converter: (store) => MeasuresViewModel(store), + builder: (BuildContext context, vm) { + return MeasureCreateItems( + grouped: vm.grouped, + measurements: job.measurements, + ); + }, + ); + } + Widget buildBody(TMTheme theme, List children) { return contact != null ? new SafeArea( @@ -185,6 +208,10 @@ class _JobsCreatePageState extends State with SnackBarProvider { if (selectedContact != null) { setState(() { contact = selectedContact; + job = job.copyWith( + contactID: contact?.id, + measurements: contact?.measurements ?? {}, + ); }); } } @@ -227,11 +254,15 @@ class _JobsCreatePageState extends State with SnackBarProvider { showInSnackBar('Please fix the errors in red before submitting.'); } else { form.save(); + showLoadingSnackBar(); job ..pendingPayment = job.price - ..images = fireImages.map((img) => img.image).toList() + ..images = fireImages + .where((img) => img.image != null) + .map((img) => img.image) + .toList() ..contactID = contact.id; try { @@ -255,6 +286,7 @@ class _JobsCreatePageState extends State with SnackBarProvider { return new Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0), child: new TextFormField( + focusNode: _additionFocusNode, keyboardType: TextInputType.text, style: TextStyle(fontSize: 18.0, color: Colors.black), maxLines: 6, @@ -264,6 +296,7 @@ class _JobsCreatePageState extends State with SnackBarProvider { hintStyle: TextStyle(fontSize: 14.0), ), onSaved: (value) => job.notes = value.trim(), + onFieldSubmitted: (value) => _handleSubmit(), ), ); } @@ -329,6 +362,9 @@ class _JobsCreatePageState extends State with SnackBarProvider { return; } final imageFile = await ImagePicker.pickImage(source: source); + if (imageFile == null) { + return; + } final ref = CloudStorage.createReferenceImage(); final uploadTask = ref.putFile(imageFile); @@ -360,6 +396,7 @@ class _JobsCreatePageState extends State with SnackBarProvider { return new Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0), child: new TextFormField( + textInputAction: TextInputAction.next, keyboardType: TextInputType.text, style: TextStyle(fontSize: 18.0, color: Colors.black), decoration: new InputDecoration( @@ -369,6 +406,8 @@ class _JobsCreatePageState extends State with SnackBarProvider { ), validator: (value) => (value.isNotEmpty) ? null : "Please input a name", onSaved: (value) => job.name = value.trim(), + onEditingComplete: () => + FocusScope.of(context).requestFocus(_amountFocusNode), ), ); } @@ -377,7 +416,9 @@ class _JobsCreatePageState extends State with SnackBarProvider { return new Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0), child: new TextFormField( + focusNode: _amountFocusNode, controller: controller, + textInputAction: TextInputAction.next, keyboardType: const TextInputType.numberWithOptions(decimal: true), style: TextStyle(fontSize: 18.0, color: Colors.black), decoration: new InputDecoration( @@ -388,6 +429,8 @@ class _JobsCreatePageState extends State with SnackBarProvider { validator: (value) => (controller.numberValue > 0) ? null : "Please input a price", onSaved: (value) => job.price = controller.numberValue, + onEditingComplete: () => + FocusScope.of(context).requestFocus(_additionFocusNode), ), ); } diff --git a/lib/pages/jobs/ui/gallery_grids.dart b/lib/pages/jobs/ui/gallery_grids.dart index 61929b0e..7536bef2 100644 --- a/lib/pages/jobs/ui/gallery_grids.dart +++ b/lib/pages/jobs/ui/gallery_grids.dart @@ -141,6 +141,9 @@ class GalleryGridsState extends State { return; } final imageFile = await ImagePicker.pickImage(source: source); + if (imageFile == null) { + return; + } final ref = CloudStorage.createReferenceImage(); final uploadTask = ref.putFile(imageFile); @@ -159,10 +162,14 @@ class GalleryGridsState extends State { ); }); - await widget.job.reference - .updateData(>>{ - "images": fireImages.map((img) => img.image.toMap()).toList(), - }); + await widget.job.reference.updateData( + >>{ + "images": fireImages + .where((img) => img.image != null) + .map((img) => img.image.toMap()) + .toList(), + }, + ); // Redraw setState(() { diff --git a/lib/pages/jobs/ui/measure_lists.dart b/lib/pages/jobs/ui/measure_lists.dart deleted file mode 100644 index 35f2cf1f..00000000 --- a/lib/pages/jobs/ui/measure_lists.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:tailor_made/models/measure.dart'; -import 'package:tailor_made/pages/jobs/ui/measure_list_item.dart'; -import 'package:tailor_made/pages/jobs/ui/measures.dart'; -import 'package:tailor_made/utils/tm_navigate.dart'; -import 'package:tailor_made/utils/tm_theme.dart'; - -class MeasureLists extends StatelessWidget { - final List measurements; - - const MeasureLists({ - Key key, - @required this.measurements, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - new Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const SizedBox(width: 16.0), - Expanded( - child: Text("MEASUREMENTS", - style: ralewayRegular(12.0, Colors.black87))), - CupertinoButton( - child: - Text("SHOW ALL", style: ralewayRegular(11.0, kTextBaseColor)), - onPressed: () => TMNavigate( - context, MeasuresPage(measurements: measurements), - fullscreenDialog: true), - ), - ], - ) - ]..addAll( - measurements - .where((item) => item.value != null && item.value > 0) - .take(5) - .map( - (item) => new MeasureListItem(item), - ) - .toList(), - ), - ); - } -} diff --git a/lib/pages/jobs/ui/measures.dart b/lib/pages/jobs/ui/measures.dart deleted file mode 100644 index 792b2670..00000000 --- a/lib/pages/jobs/ui/measures.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:tailor_made/models/measure.dart'; -import 'package:tailor_made/pages/jobs/ui/measure_list_item.dart'; -import 'package:tailor_made/ui/tm_empty_result.dart'; - -class MeasuresPage extends StatelessWidget { - final List measurements; - - const MeasuresPage({ - Key key, - this.measurements, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return new Scaffold( - appBar: AppBar( - brightness: Brightness.light, - iconTheme: IconThemeData(color: Colors.black87), - backgroundColor: Colors.transparent, - elevation: 0.0, - ), - body: getBody(), - ); - } - - Widget getBody() { - if (measurements.isEmpty) { - return Center( - child: TMEmptyResult(message: "No measurements available"), - ); - } - - return ListView.separated( - itemCount: measurements.length, - shrinkWrap: true, - padding: EdgeInsets.only(bottom: 96.0), - itemBuilder: (context, index) => MeasureListItem(measurements[index]), - separatorBuilder: (BuildContext context, int index) => new Divider(), - ); - } -} diff --git a/lib/pages/jobs/ui/payment_grids.dart b/lib/pages/jobs/ui/payment_grids.dart index 4db38afb..104bc16c 100644 --- a/lib/pages/jobs/ui/payment_grids.dart +++ b/lib/pages/jobs/ui/payment_grids.dart @@ -79,8 +79,10 @@ class PaymentGridsState extends State { style: ralewayRegular(11.0, kTextBaseColor), ), onPressed: () => TMNavigate( - context, PaymentsPage(payments: widget.job.payments), - fullscreenDialog: true), + context, + PaymentsPage(payments: widget.job.payments), + fullscreenDialog: true, + ), ), ], ), @@ -115,7 +117,11 @@ class PaymentGridsState extends State { onTap: () async { final result = await Navigator.push>( context, - TMNavigate.fadeIn>(PaymentsCreatePage()), + TMNavigate.fadeIn>( + PaymentsCreatePage( + limit: widget.job.pendingPayment, + ), + ), ); if (result != null) { setState(() { diff --git a/lib/pages/measures/measures.dart b/lib/pages/measures/measures.dart new file mode 100644 index 00000000..54029ab1 --- /dev/null +++ b/lib/pages/measures/measures.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_redux/flutter_redux.dart'; +import 'package:tailor_made/models/measure.dart'; +import 'package:tailor_made/pages/measures/ui/measure_list_item.dart'; +import 'package:tailor_made/redux/states/main.dart'; +import 'package:tailor_made/redux/view_models/measures.dart'; +import 'package:tailor_made/ui/tm_empty_result.dart'; + +class MeasuresPage extends StatelessWidget { + final Map measurements; + + const MeasuresPage({ + Key key, + this.measurements, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return StoreConnector( + converter: (store) => MeasuresViewModel(store), + builder: (BuildContext context, vm) { + return new Scaffold( + appBar: AppBar( + brightness: Brightness.light, + iconTheme: IconThemeData(color: Colors.black87), + backgroundColor: Colors.transparent, + elevation: 0.0, + ), + body: getBody(vm.measures), + ); + }, + ); + } + + Widget getBody(List measures) { + if (measures.isEmpty) { + return Center( + child: TMEmptyResult(message: "No measurements available"), + ); + } + + return ListView.separated( + itemCount: measures.length, + shrinkWrap: true, + padding: EdgeInsets.only(bottom: 96.0), + itemBuilder: (context, index) { + final measure = measures[index]; + final _value = measurements[measure.id] ?? 0.0; + return MeasureListItem(measure..value = _value); + }, + separatorBuilder: (BuildContext context, int index) => new Divider(), + ); + } +} diff --git a/lib/pages/measures/measures_create.dart b/lib/pages/measures/measures_create.dart new file mode 100644 index 00000000..423d95ee --- /dev/null +++ b/lib/pages/measures/measures_create.dart @@ -0,0 +1,325 @@ +import 'dart:async'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_redux/flutter_redux.dart'; +import 'package:tailor_made/models/measure.dart'; +import 'package:tailor_made/pages/measures/ui/measure_dialog.dart'; +import 'package:tailor_made/redux/states/main.dart'; +import 'package:tailor_made/redux/view_models/measures.dart'; +import 'package:tailor_made/services/cloud_db.dart'; +import 'package:tailor_made/utils/tm_confirm_dialog.dart'; +import 'package:tailor_made/utils/tm_navigate.dart'; +import 'package:tailor_made/utils/tm_snackbar.dart'; +import 'package:tailor_made/utils/tm_theme.dart'; + +class MeasuresCreate extends StatefulWidget { + final List measures; + final String groupName, unitValue; + + const MeasuresCreate({ + Key key, + this.measures, + this.groupName, + this.unitValue, + }) : super(key: key); + + @override + MeasuresCreateState createState() => new MeasuresCreateState(); +} + +class MeasuresCreateState extends State with SnackBarProvider { + final GlobalKey _formKey = new GlobalKey(); + bool _autovalidate = false; + String groupName, unitValue; + List measures; + + @override + void initState() { + super.initState(); + measures = widget.measures ?? []; + groupName = widget.groupName ?? ""; + unitValue = widget.unitValue ?? ""; + } + + @override + final scaffoldKey = new GlobalKey(); + + @override + Widget build(BuildContext context) { + final TMTheme theme = TMTheme.of(context); + return StoreConnector( + converter: (store) => MeasuresViewModel(store), + builder: (BuildContext context, vm) { + final List children = []; + + children.add(makeHeader("Group Name")); + children.add(buildEnterName()); + + children.add(makeHeader("Group Unit")); + children.add(buildEnterUnit()); + + if (measures.isNotEmpty) { + children.add(makeHeader("Group Items")); + children.add(buildGroupItems(vm)); + + children.add(SizedBox(height: 84.0)); + } + + return Scaffold( + key: scaffoldKey, + backgroundColor: theme.scaffoldColor, + appBar: AppBar( + brightness: Brightness.light, + centerTitle: false, + elevation: 1.0, + actions: [ + FlatButton( + child: Text("SAVE", style: TextStyle(fontSize: 20.0)), + onPressed: measures.isEmpty ? null : () => _handleSubmit(vm), + ) + ], + ), + body: Theme( + data: ThemeData( + hintColor: kHintColor, + primaryColor: kPrimaryColor, + ), + child: buildBody(children), + ), + floatingActionButtonLocation: + FloatingActionButtonLocation.centerFloat, + floatingActionButton: FloatingActionButton.extended( + icon: new Icon(Icons.add_circle_outline), + backgroundColor: Colors.white, + foregroundColor: kAccentColor, + label: Text("Add Item"), + onPressed: _handleAddItem, + ), + ); + }, + ); + } + + Widget makeHeader(String title, [String trailing = ""]) { + return new Container( + color: Colors.grey[100].withOpacity(.4), + margin: const EdgeInsets.only(top: 8.0), + padding: const EdgeInsets.only( + top: 8.0, + bottom: 8.0, + left: 16.0, + right: 16.0, + ), + alignment: AlignmentDirectional.centerStart, + child: new Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title.toUpperCase(), + style: ralewayLight(12.0, kTextBaseColor.shade800), + ), + Text(trailing, style: ralewayLight(12.0, kTextBaseColor.shade800)), + ], + ), + ); + } + + Widget buildEnterName() { + return new Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0), + child: new TextFormField( + initialValue: groupName, + textCapitalization: TextCapitalization.words, + keyboardType: TextInputType.text, + style: TextStyle(fontSize: 18.0, color: Colors.black), + decoration: new InputDecoration( + isDense: true, + hintText: "eg Blouse", + hintStyle: TextStyle(fontSize: 14.0), + ), + validator: (value) => (value.isNotEmpty) ? null : "Please input a name", + onSaved: (value) => groupName = value.trim(), + ), + ); + } + + Widget buildEnterUnit() { + return new Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0), + child: new TextFormField( + initialValue: unitValue, + keyboardType: TextInputType.text, + style: TextStyle(fontSize: 18.0, color: Colors.black), + decoration: new InputDecoration( + isDense: true, + hintText: "Unit (eg. In, cm)", + hintStyle: TextStyle(fontSize: 14.0), + ), + validator: (value) => + (value.isNotEmpty) ? null : "Please input a value", + onSaved: (value) => unitValue = value.trim(), + ), + ); + } + + Widget buildBody(List children) { + return new SafeArea( + top: false, + child: new SingleChildScrollView( + child: Form( + key: _formKey, + autovalidate: _autovalidate, + child: new Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), + ), + ), + ); + } + + Widget buildGroupItems(MeasuresViewModel vm) { + final items = List.generate(measures.length, (index) { + final measure = measures[index]; + return ListTile( + dense: true, + title: Text(measure.name), + subtitle: Text(measure.unit), + trailing: IconButton( + icon: Icon( + measure?.reference != null + ? Icons.delete + : Icons.remove_circle_outline, + ), + iconSize: 20.0, + onPressed: () { + if (measure?.reference != null) { + onTapDeleteItem(vm, measure); + } + setState(() { + measures = measures..removeAt(index); + }); + }, + ), + ); + }); + return new Column( + children: items.toList(), + ); + } + + void onTapDeleteItem(MeasuresViewModel vm, MeasureModel measure) async { + final choice = await confirmDialog( + context: context, + content: Text("Are you sure?"), + ); + if (choice == null || choice == false) { + return; + } + + showLoadingSnackBar(); + + try { + vm.toggleLoading(); + await measure.reference.delete(); + closeLoadingSnackBar(); + } catch (e) { + closeLoadingSnackBar(); + showInSnackBar(e.toString()); + } + } + + void _handleAddItem() async { + if (_isOkForm()) { + final _measure = await _itemModal(); + + if (_measure == null) { + return; + } + + setState(() { + measures = [_measure]..addAll(measures); + }); + } + } + + Future _itemModal() { + return Navigator.push( + context, + TMNavigate.fadeIn( + Scaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0.0, + automaticallyImplyLeading: false, + leading: IconButton( + icon: Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ), + backgroundColor: Colors.black38, + body: MeasureDialog( + measure: new MeasureModel( + name: "", + group: groupName, + unit: unitValue, + ), + ), + ), + ), + ); + } + + void _handleSubmit(MeasuresViewModel vm) async { + if (_isOkForm()) { + final WriteBatch batch = CloudDb.instance.batch(); + + measures.forEach((measure) { + if (measure?.reference != null) { + batch.updateData( + measure.reference, + { + "group": groupName, + "unit": unitValue, + }, + ); + } else { + batch.setData( + CloudDb.measurements.document(measure.id), + measure.toMap(), + merge: true, + ); + } + }); + + showLoadingSnackBar(); + try { + vm.toggleLoading(); + await batch.commit(); + + closeLoadingSnackBar(); + Navigator.pop(context); + } catch (e) { + closeLoadingSnackBar(); + showInSnackBar(e.toString()); + } + } + } + + bool _isOkForm() { + final FormState form = _formKey.currentState; + if (form == null) { + return false; + } + if (!form.validate()) { + _autovalidate = true; // Start validating on every change. + return false; + } else { + form.save(); + return true; + } + } +} diff --git a/lib/pages/measures/measures_manage.dart b/lib/pages/measures/measures_manage.dart new file mode 100644 index 00000000..83e861e8 --- /dev/null +++ b/lib/pages/measures/measures_manage.dart @@ -0,0 +1,112 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_redux/flutter_redux.dart'; +import 'package:tailor_made/models/account.dart'; +import 'package:tailor_made/pages/measures/measures_create.dart'; +import 'package:tailor_made/pages/measures/ui/measures_slide_block.dart'; +import 'package:tailor_made/redux/states/main.dart'; +import 'package:tailor_made/redux/view_models/measures.dart'; +import 'package:tailor_made/ui/app_bar.dart'; +import 'package:tailor_made/ui/tm_empty_result.dart'; +import 'package:tailor_made/ui/tm_loading_spinner.dart'; +import 'package:tailor_made/utils/tm_navigate.dart'; +import 'package:tailor_made/utils/tm_snackbar.dart'; +import 'package:tailor_made/utils/tm_theme.dart'; + +class MeasuresManagePage extends StatefulWidget { + final AccountModel account; + + const MeasuresManagePage({ + Key key, + @required this.account, + }) : super(key: key); + + @override + MeasuresManagePageState createState() => new MeasuresManagePageState(); +} + +class MeasuresManagePageState extends State + with SnackBarProvider { + @override + final scaffoldKey = new GlobalKey(); + + @override + void initState() { + super.initState(); + Future.delayed( + Duration(seconds: 2), + () => showInSnackBar("Long-Press on any group to see more actions."), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + key: scaffoldKey, + appBar: appBar( + context, + title: "Measurements", + ), + body: _buildBody(), + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, + floatingActionButton: FloatingActionButton.extended( + icon: new Icon(Icons.add), + backgroundColor: kAccentColor, + foregroundColor: Colors.white, + label: Text("Add Group"), + onPressed: () => TMNavigate( + context, + MeasuresCreate(), + fullscreenDialog: true, + ), + ), + ); + } + + Widget _buildBody() { + return new StoreConnector( + converter: (store) => MeasuresViewModel(store), + builder: (BuildContext context, vm) { + if (vm.isLoading) { + return Center( + child: loadingSpinner(), + ); + } + + if (vm.measures == null || vm.measures.isEmpty) { + return Center( + child: TMEmptyResult(message: "No measurements available"), + ); + } + + final slides = []; + + vm.grouped.forEach((key, data) { + slides.add( + MeasureSlideBlock( + title: key, + measures: data.toList(), + parent: this, + ), + ); + }); + + return new SafeArea( + top: false, + child: new SingleChildScrollView( + child: Column( + children: slides + ..add( + SizedBox( + height: 72.0, + ), + ) + ..toList(), + ), + ), + ); + }, + ); + } +} diff --git a/lib/pages/jobs/ui/measure_create_items.dart b/lib/pages/measures/ui/measure_create_items.dart similarity index 58% rename from lib/pages/jobs/ui/measure_create_items.dart rename to lib/pages/measures/ui/measure_create_items.dart index 3156135f..1fe16445 100644 --- a/lib/pages/jobs/ui/measure_create_items.dart +++ b/lib/pages/measures/ui/measure_create_items.dart @@ -1,63 +1,52 @@ import 'package:flutter/material.dart'; import 'package:tailor_made/models/measure.dart'; -import 'package:tailor_made/pages/jobs/ui/slide_down.dart'; +import 'package:tailor_made/ui/slide_down.dart'; import 'package:tailor_made/utils/tm_theme.dart'; class MeasureCreateItems extends StatelessWidget { - final List measurements; + final Map> grouped; + final Map measurements; - const MeasureCreateItems(this.measurements); + const MeasureCreateItems({ + Key key, + @required this.grouped, + @required this.measurements, + }) : super(key: key); @override Widget build(BuildContext context) { - return Column( - children: [ - new SlideDownItem( - title: MeasureModelType.blouse, - body: new JobMeasureBlock( - measurements - .where((measure) => measure.type == MeasureModelType.blouse) - .toList(), - ), - isExpanded: true, - ), - new SlideDownItem( - title: MeasureModelType.trouser, - body: new JobMeasureBlock( - measurements - .where((measure) => measure.type == MeasureModelType.trouser) - .toList(), - ), - ), - new SlideDownItem( - title: MeasureModelType.skirts, - body: new JobMeasureBlock( - measurements - .where((measure) => measure.type == MeasureModelType.skirts) - .toList(), - ), - ), - new SlideDownItem( - title: MeasureModelType.gown, - body: new JobMeasureBlock( - measurements - .where((measure) => measure.type == MeasureModelType.gown) - .toList(), - ), + final slides = []; + + grouped.forEach((key, data) { + slides.add(new SlideDownItem( + title: key, + body: new JobMeasureBlock( + measures: data.toList(), + measurements: measurements, ), - ], + // isExpanded: true, + )); + }); + + return Column( + children: slides.toList(), ); } } class JobMeasureBlock extends StatelessWidget { - final List list; + final List measures; + final Map measurements; - const JobMeasureBlock(this.list); + const JobMeasureBlock({ + Key key, + @required this.measures, + @required this.measurements, + }) : super(key: key); @override Widget build(BuildContext context) { - final length = list.length; + final length = measures.length; return Theme( data: ThemeData(primaryColor: kPrimaryColor), child: Container( @@ -69,8 +58,16 @@ class JobMeasureBlock extends StatelessWidget { ), child: Wrap( alignment: WrapAlignment.spaceBetween, - children: list.map((MeasureModel measure) { - final index = list.indexOf(measure); + children: measures.map((MeasureModel measure) { + final index = measures.indexOf(measure); + + final _value = measurements.containsKey(measure.id) + ? measurements[measure.id] + : 0; + final value = + _value != null && _value > 0 ? _value.toString() : ""; + final _controller = TextEditingController(text: value); + return new LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { final removeBorder = @@ -94,9 +91,8 @@ class JobMeasureBlock extends StatelessWidget { ), width: constraints.maxWidth / 2, child: TextFormField( - initialValue: measure.value != null && measure.value > 0 - ? measure.value.toString() - : "", + // initialValue: value, + controller: _controller, keyboardType: const TextInputType.numberWithOptions(decimal: true), style: TextStyle(fontSize: 20.0, color: Colors.black), @@ -107,8 +103,9 @@ class JobMeasureBlock extends StatelessWidget { labelStyle: TextStyle(fontSize: 14.0), ), onFieldSubmitted: (value) => - measure.value = double.tryParse(value), - onSaved: (value) => measure.value = double.tryParse(value), + measurements[measure.id] = double.tryParse(value), + onSaved: (value) => + measurements[measure.id] = double.tryParse(value), ), ); }, diff --git a/lib/pages/measures/ui/measure_dialog.dart b/lib/pages/measures/ui/measure_dialog.dart new file mode 100644 index 00000000..2205fea1 --- /dev/null +++ b/lib/pages/measures/ui/measure_dialog.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:tailor_made/models/measure.dart'; +import 'package:tailor_made/utils/tm_theme.dart'; +import 'package:tailor_made/utils/tm_validators.dart'; + +class MeasureDialog extends StatefulWidget { + final MeasureModel measure; + + const MeasureDialog({ + Key key, + @required this.measure, + }) : super(key: key); + + @override + MeasureDialogState createState() => new MeasureDialogState(); +} + +class MeasureDialogState extends State { + final FocusNode _unitNode = new FocusNode(); + final GlobalKey _formKey = new GlobalKey(); + bool _autovalidate = false; + MeasureModel measure; + + @override + void initState() { + super.initState(); + measure = widget.measure; + } + + @override + void dispose() { + _unitNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Material( + color: Colors.white, + borderRadius: BorderRadius.circular(5.0), + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: Theme( + data: ThemeData( + hintColor: kHintColor, + primaryColor: kPrimaryColor, + ), + child: new Form( + key: _formKey, + autovalidate: _autovalidate, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 32.0), + Center( + child: CircleAvatar( + backgroundColor: kPrimaryColor, + foregroundColor: Colors.white, + child: Icon( + Icons.content_cut, + size: 50.0, + ), + radius: 36.0, + ), + ), + const SizedBox(height: 16.0), + TextFormField( + textCapitalization: TextCapitalization.words, + textInputAction: TextInputAction.next, + onEditingComplete: () => + FocusScope.of(context).requestFocus(_unitNode), + onSaved: (value) => widget.measure.name = value.trim(), + decoration: InputDecoration( + labelText: "Name (eg. Length)", + ), + style: TextStyle(fontSize: 14.0, color: Colors.black), + validator: validateAlpha(), + ), + SizedBox(height: 4.0), + TextFormField( + initialValue: widget.measure.unit, + focusNode: _unitNode, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelText: "Unit (eg. In, cm)", + ), + style: TextStyle(fontSize: 14.0, color: Colors.black), + validator: validateAlpha(), + onFieldSubmitted: (value) => _onSaved(), + onSaved: (value) => widget.measure.unit = value.trim(), + ), + const SizedBox(height: 32.0), + ], + ), + ), + ), + ), + ), + ); + } + + void _onSaved() { + final FormState form = _formKey.currentState; + if (form == null) { + return; + } + if (!form.validate()) { + _autovalidate = true; // Start validating on every change. + // widget.onHandleValidate(); + } else { + form.save(); + Navigator.pop(context, measure); + } + } +} diff --git a/lib/pages/measures/ui/measure_edit_dialog.dart b/lib/pages/measures/ui/measure_edit_dialog.dart new file mode 100644 index 00000000..96423671 --- /dev/null +++ b/lib/pages/measures/ui/measure_edit_dialog.dart @@ -0,0 +1,69 @@ +import 'dart:async' show Future; + +import 'package:flutter/material.dart'; +import 'package:tailor_made/utils/tm_child_dialog.dart'; +import 'package:tailor_made/utils/tm_theme.dart'; + +Future showEditDialog({ + @required BuildContext context, + @required List children, + @required String title, +}) { + return showChildDialog( + context: context, + child: new MeasureEditDialog( + title: title, + children: children, + ), + ); +} + +class MeasureEditDialog extends StatelessWidget { + final String title; + final List children; + + const MeasureEditDialog({ + Key key, + @required this.title, + @required this.children, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Align( + alignment: FractionalOffset(0.0, 0.25), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0), + child: Material( + borderRadius: BorderRadius.circular(4.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(height: 16.0), + Center( + child: Text(title, style: ralewayLight(12.0)), + ), + SizedBox(height: 8.0), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 16.0, + horizontal: 16.0, + ), + child: Theme( + data: ThemeData( + hintColor: kHintColor, + primaryColor: kPrimaryColor, + ), + child: Column( + children: children, + ), + ), + ), + SizedBox(height: 16.0), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/jobs/ui/measure_list_item.dart b/lib/pages/measures/ui/measure_list_item.dart similarity index 96% rename from lib/pages/jobs/ui/measure_list_item.dart rename to lib/pages/measures/ui/measure_list_item.dart index 16d4784f..1b49c0a7 100644 --- a/lib/pages/jobs/ui/measure_list_item.dart +++ b/lib/pages/measures/ui/measure_list_item.dart @@ -18,7 +18,7 @@ class MeasureListItem extends StatelessWidget { SizedBox(height: 2.0), Container( padding: EdgeInsets.symmetric(horizontal: 8.0, vertical: 1.0), - child: Text(item.type.toLowerCase(), + child: Text(item.group.toLowerCase(), style: ralewayRegular(10.0, Colors.white)), decoration: BoxDecoration( color: kAccentColor, diff --git a/lib/pages/measures/ui/measures_slide_block.dart b/lib/pages/measures/ui/measures_slide_block.dart new file mode 100644 index 00000000..20544990 --- /dev/null +++ b/lib/pages/measures/ui/measures_slide_block.dart @@ -0,0 +1,133 @@ +import 'dart:async'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:flutter/material.dart'; +import 'package:tailor_made/models/measure.dart'; +import 'package:tailor_made/pages/measures/measures_create.dart'; +import 'package:tailor_made/pages/measures/measures_manage.dart'; +import 'package:tailor_made/pages/measures/ui/measures_slide_block_item.dart'; +import 'package:tailor_made/services/cloud_db.dart'; +import 'package:tailor_made/ui/slide_down.dart'; +import 'package:tailor_made/utils/tm_child_dialog.dart'; +import 'package:tailor_made/utils/tm_confirm_dialog.dart'; +import 'package:tailor_made/utils/tm_navigate.dart'; + +enum ActionChoice { + edit, + delete, +} + +class MeasureSlideBlock extends StatefulWidget { + final List measures; + final MeasuresManagePageState parent; + final String title; + + const MeasureSlideBlock({ + Key key, + @required this.measures, + @required this.parent, + @required this.title, + }) : super(key: key); + + @override + _MeasureSlideBlockState createState() => new _MeasureSlideBlockState(); +} + +class _MeasureSlideBlockState extends State { + @override + Widget build(BuildContext context) { + final children = widget.measures + .map((measure) => MeasuresSlideBlockItem( + measure: measure, + parent: widget.parent, + )) + .toList(); + + return new SlideDownItem( + title: widget.title, + body: Container( + // color: kBorderSideColor.withOpacity(.25), + child: Column( + children: children, + ), + ), + onLongPress: () async { + final choice = await _showOptionsDialog(); + + if (choice == null) { + return; + } + + if (choice == ActionChoice.edit) { + onTapEditBlock(); + } else if (choice == ActionChoice.delete) { + onTapDeleteBlock(); + } + }, + ); + } + + Future _showOptionsDialog() { + return showChildDialog( + context: context, + child: new SimpleDialog( + children: [ + new SimpleDialogOption( + onPressed: () => Navigator.pop(context, ActionChoice.edit), + child: Padding( + child: Text("Edit"), + padding: EdgeInsets.all(8.0), + ), + ), + new SimpleDialogOption( + onPressed: () => Navigator.pop(context, ActionChoice.delete), + child: Padding( + child: Text("Delete"), + padding: EdgeInsets.all(8.0), + ), + ), + ], + ), + ); + } + + void onTapEditBlock() { + TMNavigate( + context, + MeasuresCreate( + groupName: widget.title, + unitValue: widget.measures.first.unit, + measures: widget.measures, + ), + fullscreenDialog: true, + ); + } + + void onTapDeleteBlock() async { + final choice = await confirmDialog( + context: context, + content: Text("Are you sure?"), + ); + if (choice == null || choice == false) { + return; + } + + final WriteBatch batch = CloudDb.instance.batch(); + + widget.measures.forEach((measure) { + batch.delete( + CloudDb.measurements.document(measure.id), + ); + }); + + widget.parent.showLoadingSnackBar(); + try { + await batch.commit(); + + widget.parent.closeLoadingSnackBar(); + } catch (e) { + widget.parent.closeLoadingSnackBar(); + widget.parent.showInSnackBar(e.toString()); + } + } +} diff --git a/lib/pages/measures/ui/measures_slide_block_item.dart b/lib/pages/measures/ui/measures_slide_block_item.dart new file mode 100644 index 00000000..88a6e57e --- /dev/null +++ b/lib/pages/measures/ui/measures_slide_block_item.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:tailor_made/models/measure.dart'; +import 'package:tailor_made/pages/measures/measures_manage.dart'; +import 'package:tailor_made/pages/measures/ui/measure_edit_dialog.dart'; +import 'package:tailor_made/utils/tm_theme.dart'; + +class MeasuresSlideBlockItem extends StatefulWidget { + final MeasureModel measure; + final MeasuresManagePageState parent; + + const MeasuresSlideBlockItem({ + Key key, + @required this.measure, + @required this.parent, + }) : super(key: key); + + @override + _MeasuresSlideBlockItemState createState() => + new _MeasuresSlideBlockItemState(); +} + +class _MeasuresSlideBlockItemState extends State { + @override + Widget build(BuildContext context) { + return ListTile( + dense: true, + title: Text(widget.measure.name), + subtitle: Text(widget.measure.unit), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + InkResponse( + child: Icon( + Icons.settings, + color: kPrimaryColor.withOpacity(.75), + ), + onTap: () => onTapEditItem(widget.measure), + ), + ], + ), + ); + } + + void onTapEditItem(MeasureModel measure) async { + final _controller = TextEditingController(text: measure.name); + final itemName = await showEditDialog( + context: context, + title: "ITEM NAME", + children: [ + TextField( + textCapitalization: TextCapitalization.words, + controller: _controller, + onSubmitted: (value) => Navigator.pop(context, value.trim()), + ), + ], + ); + + if (itemName == null) { + return; + } + + widget.parent.showLoadingSnackBar(); + + try { + await measure.reference.updateData({ + "name": itemName, + // "unit": "", + }); + widget.parent.closeLoadingSnackBar(); + } catch (e) { + widget.parent.closeLoadingSnackBar(); + widget.parent.showInSnackBar(e.toString()); + } + } +} diff --git a/lib/pages/payments/payment.dart b/lib/pages/payments/payment.dart index f97f1f4b..072be2db 100644 --- a/lib/pages/payments/payment.dart +++ b/lib/pages/payments/payment.dart @@ -29,8 +29,6 @@ class PaymentPage extends StatelessWidget { ..contactID = payment.contactID ..jobID = payment.jobID, builder: (BuildContext context, ContactJobViewModel vm) { - final contact = vm.selectedContact; - final job = vm.selectedJob; return Scaffold( appBar: AppBar( iconTheme: IconThemeData(color: Colors.black87), @@ -43,24 +41,27 @@ class PaymentPage extends StatelessWidget { Icons.work, color: kTitleBaseColor, ), - onPressed: () => TMNavigate(context, JobPage(job: job)), - ), - IconButton( - icon: Icon( - Icons.person, - color: kTitleBaseColor, - ), onPressed: () => - TMNavigate(context, ContactPage(contact: contact)), + TMNavigate(context, JobPage(job: vm.selectedJob)), ), IconButton( icon: Icon( - Icons.share, + Icons.person, color: kTitleBaseColor, ), - // TODO - onPressed: null, + onPressed: () => TMNavigate( + context, ContactPage(contact: vm.selectedContact)), ), + vm.account.hasPremiumEnabled + ? IconButton( + icon: Icon( + Icons.share, + color: kTitleBaseColor, + ), + // TODO + onPressed: null, + ) + : SizedBox(), ], ), body: Column( diff --git a/lib/pages/payments/payments_create.dart b/lib/pages/payments/payments_create.dart index 5ebf5244..1999cdd1 100644 --- a/lib/pages/payments/payments_create.dart +++ b/lib/pages/payments/payments_create.dart @@ -2,11 +2,17 @@ import 'package:flutter/material.dart'; import 'package:flutter_masked_text/flutter_masked_text.dart'; import 'package:tailor_made/ui/app_bar.dart'; import 'package:tailor_made/ui/full_button.dart'; +import 'package:tailor_made/utils/tm_format_naira.dart'; import 'package:tailor_made/utils/tm_snackbar.dart'; import 'package:tailor_made/utils/tm_theme.dart'; class PaymentsCreatePage extends StatefulWidget { - const PaymentsCreatePage({Key key}) : super(key: key); + final double limit; + + const PaymentsCreatePage({ + Key key, + @required this.limit, + }) : super(key: key); @override _PaymentsCreatePageState createState() => new _PaymentsCreatePageState(); @@ -22,10 +28,19 @@ class _PaymentsCreatePageState extends State decimalSeparator: '.', thousandSeparator: ',', ); + final FocusNode _amountFocusNode = new FocusNode(); + final FocusNode _additionFocusNode = new FocusNode(); @override final scaffoldKey = new GlobalKey(); + @override + void dispose() { + _amountFocusNode.dispose(); + _additionFocusNode.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final TMTheme theme = TMTheme.of(context); @@ -103,7 +118,9 @@ class _PaymentsCreatePageState extends State return new Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0), child: new TextFormField( + focusNode: _amountFocusNode, controller: controller, + textInputAction: TextInputAction.next, keyboardType: const TextInputType.numberWithOptions(decimal: true), style: TextStyle(fontSize: 18.0, color: Colors.black), decoration: new InputDecoration( @@ -111,9 +128,15 @@ class _PaymentsCreatePageState extends State hintText: "Enter Amount", hintStyle: TextStyle(fontSize: 14.0), ), - validator: (value) => - (controller.numberValue > 0) ? null : "Please input a price", + validator: (value) { + if (controller.numberValue > widget.limit) { + return formatNaira(widget.limit) + " is the remainder on this job."; + } + return (controller.numberValue > 0) ? null : "Please input a price"; + }, onSaved: (value) => price = controller.numberValue, + onEditingComplete: () => + FocusScope.of(context).requestFocus(_additionFocusNode), ), ); } @@ -122,6 +145,7 @@ class _PaymentsCreatePageState extends State return new Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0), child: new TextFormField( + focusNode: _additionFocusNode, keyboardType: TextInputType.text, style: TextStyle(fontSize: 18.0, color: Colors.black), maxLines: 6, @@ -131,6 +155,7 @@ class _PaymentsCreatePageState extends State hintStyle: TextStyle(fontSize: 14.0), ), onSaved: (value) => notes = value.trim(), + onFieldSubmitted: (value) => _handleSubmit(), ), ); } diff --git a/lib/pages/splash/splash.dart b/lib/pages/splash/splash.dart index 90f3ac4f..84a0b932 100644 --- a/lib/pages/splash/splash.dart +++ b/lib/pages/splash/splash.dart @@ -1,12 +1,14 @@ import 'dart:async'; -import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_redux/flutter_redux.dart'; import 'package:get_version/get_version.dart'; import 'package:tailor_made/pages/homepage/homepage.dart'; +import 'package:tailor_made/redux/actions/settings.dart'; +import 'package:tailor_made/redux/states/main.dart'; +import 'package:tailor_made/redux/view_models/settings.dart'; import 'package:tailor_made/services/auth.dart'; -import 'package:tailor_made/services/cloud_db.dart'; import 'package:tailor_made/services/settings.dart'; import 'package:tailor_made/ui/tm_loading_spinner.dart'; import 'package:tailor_made/utils/tm_images.dart'; @@ -42,10 +44,6 @@ class _SplashPageState extends State with SnackBarProvider { _getVersionName(); - if (widget.isColdStart == true) { - _trySilent(); - } - Auth.onAuthStateChanged.firstWhere((user) => user != null).then( (user) { Auth.setUser(user); @@ -70,7 +68,7 @@ class _SplashPageState extends State with SnackBarProvider { } } - Future _trySilent() async { + Future _tryLoginSilent() async { // Give the navigation animations, etc, some time to finish await new Future.delayed(new Duration(seconds: 1)) .then((dynamic _) => _onLogin()); @@ -82,16 +80,27 @@ class _SplashPageState extends State with SnackBarProvider { String message = ""; switch (e?.code) { case "exception": + case "sign_in_failed": if (e?.message?.contains("administrator") ?? false) { message = - "It seems this account has been disabled. Contact Administrators."; + "It seems this account has been disabled. Contact an Admin."; break; } - message = "You need a stable internet connection to proceed"; - break; + if (e?.message?.contains("NETWORK_ERROR") ?? false) { + message = "Please check if you have your internet switched on."; + break; + } + if (e?.message?.contains("network") ?? false) { + message = "A stable internet connection is required."; + break; + } + continue fallthrough; + + fallthrough: case "sign_in_failed": - message = "Sorry, We could not connect to Google using that account."; + message = "Sorry, We could not connect. Try again."; break; + case "canceled": default: } @@ -113,77 +122,113 @@ class _SplashPageState extends State with SnackBarProvider { Widget build(BuildContext context) { return new Scaffold( key: scaffoldKey, - body: Stack( - fit: StackFit.expand, - children: [ - Opacity( - opacity: .5, - child: Container( - decoration: BoxDecoration( - image: DecorationImage( - image: TMImages.pattern, - fit: BoxFit.cover, + body: new StoreConnector( + converter: (store) => SettingsViewModel(store), + onInit: (store) => store.dispatch(new InitSettingsEvents()), + builder: (context, vm) { + return Stack( + fit: StackFit.expand, + children: [ + Opacity( + opacity: .5, + child: Container( + decoration: BoxDecoration( + image: DecorationImage( + image: TMImages.pattern, + fit: BoxFit.cover, + ), + ), ), ), - ), - ), - Positioned.fill( - top: null, - bottom: 32.0, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - TMStrings.appName, - style: ralewayMedium(22.0, kTextBaseColor.withOpacity(.6)), - textAlign: TextAlign.center, - ), - projectVersion != null - ? Text( - "v" + projectVersion, - style: - ralewayMedium(12.0, kTextBaseColor.withOpacity(.4)) + Positioned.fill( + top: null, + bottom: 32.0, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + TMStrings.appName, + style: + ralewayMedium(22.0, kTextBaseColor.withOpacity(.6)), + textAlign: TextAlign.center, + ), + projectVersion != null + ? Text( + "v" + projectVersion, + style: ralewayMedium( + 12.0, kTextBaseColor.withOpacity(.4)) .copyWith(height: 1.5), - textAlign: TextAlign.center, - ) - : SizedBox(), - ], - ), - ), - isLoading && (widget.isColdStart || isRestartable) - ? SizedBox() - : Center( - child: Image( - image: TMImages.logo, - width: 148.0, - color: Colors.white.withOpacity(.35), - colorBlendMode: BlendMode.saturation, - ), + textAlign: TextAlign.center, + ) + : SizedBox(), + ], ), - Positioned( - height: 96.0, - bottom: 72.0, - left: 0.0, - right: 0.0, - child: StreamBuilder( - stream: CloudDb.settings.snapshots(), - builder: (context, AsyncSnapshot snapshot) { - if (!snapshot.hasData || - (snapshot.hasData && snapshot.data.data == null)) { - return Center( - child: loadingSpinner(), - ); - } - - Settings.setData(snapshot.data.data); - - return isLoading - ? loadingSpinner() - : Center(child: _googleBtn()); - }, + ), + _isImageVisible(vm) + ? SizedBox() + : Center( + child: Image( + image: TMImages.logo, + width: 148.0, + color: Colors.white.withOpacity(.35), + colorBlendMode: BlendMode.saturation, + ), + ), + Positioned( + height: 124.0, + bottom: 72.0, + left: 0.0, + right: 0.0, + child: _buildContent(vm), + ), + ], + ); + }, + ), + ); + } + + bool _isImageVisible(SettingsViewModel vm) { + return isLoading && (widget.isColdStart || isRestartable) && !vm.isFailure; + } + + Widget _buildContent(SettingsViewModel vm) { + if (vm.isLoading && widget.isColdStart) { + return Center( + child: loadingSpinner(), + ); + } + + if (vm.isFailure) { + return _buildFailure(vm); + } + + if (widget.isColdStart && !isRestartable) { + _tryLoginSilent(); + } + + return isLoading ? loadingSpinner() : Center(child: _googleBtn()); + } + + Widget _buildFailure(SettingsViewModel vm) { + return Padding( + padding: const EdgeInsets.fromLTRB(48.0, 16.0, 48.0, 16.0), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Text( + "You need a stable internet connection to proceed.", + textAlign: TextAlign.center, ), - ), - ], + SizedBox(height: 8.0), + RaisedButton( + color: Colors.white, + onPressed: vm.init, + child: Text("RETRY"), + ), + ], + ), ), ); } @@ -201,8 +246,10 @@ class _SplashPageState extends State with SnackBarProvider { } }, icon: Image(image: TMImages.google_logo, width: 24.0), - label: Text("Continue with Google", - style: TextStyle(fontWeight: FontWeight.w700)), + label: Text( + "Continue with Google", + style: TextStyle(fontWeight: FontWeight.w700), + ), ); } } diff --git a/lib/pages/templates/out_dated.dart b/lib/pages/templates/out_dated.dart index 198b6b55..45c11753 100644 --- a/lib/pages/templates/out_dated.dart +++ b/lib/pages/templates/out_dated.dart @@ -23,7 +23,7 @@ class OutDatedPage extends StatelessWidget { ), SizedBox(height: 48.0), Text( - "OUTDATED NOTICE", + "OUT OF DATE", style: textTheme.copyWith( color: Colors.black87, fontWeight: FontWeight.w700), textAlign: TextAlign.center, @@ -32,7 +32,7 @@ class OutDatedPage extends StatelessWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: 64.0), child: Text( - "It appears you are running an outdated version of the app.\n If this is the case, please contact an Administrator.", + "It appears you are running an outdated version of the app.\n If this is not the case, please contact an Administrator.", style: textTheme.copyWith(color: Colors.grey.shade700), textAlign: TextAlign.center, ), diff --git a/lib/pages/templates/rate_limit.dart b/lib/pages/templates/rate_limit.dart index 35ae3f3f..87dd68c9 100644 --- a/lib/pages/templates/rate_limit.dart +++ b/lib/pages/templates/rate_limit.dart @@ -34,7 +34,7 @@ class RateLimitPage extends StatelessWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: 64.0), child: Text( - "We noticed \n you really enjoy using our service. \n\n Would you mind \n paying a little token if you wish to extend beyond our usage policy?", + "We noticed \n you really enjoy using our service. \n\n Would you mind \n paying a little token if you wish to extend beyond your usage limits?", style: textTheme.copyWith(color: Colors.grey.shade700), textAlign: TextAlign.center, ), diff --git a/lib/redux/actions/account.dart b/lib/redux/actions/account.dart index 0d943e4e..ee800dea 100644 --- a/lib/redux/actions/account.dart +++ b/lib/redux/actions/account.dart @@ -1,37 +1,32 @@ +import 'package:meta/meta.dart'; import 'package:tailor_made/models/account.dart'; import 'package:tailor_made/redux/actions/main.dart'; -class InitAccount extends ActionType { - @override - final String type = ReduxActions.initAccount; - - InitAccount({AccountModel payload}) : super(payload: payload); -} - -class OnDataEvent extends ActionType { - @override - final String type = ReduxActions.onDataEventAccount; - - OnDataEvent({AccountModel payload}) : super(payload: payload); +class OnDataAccountEvent extends ActionType { + OnDataAccountEvent({ + @required AccountModel payload, + }) : super(payload: payload); } class OnPremiumSignUp extends ActionType { - @override - final String type = ReduxActions.onPremiumSignUp; - - OnPremiumSignUp({AccountModel payload}) : super(payload: payload); + OnPremiumSignUp({ + @required AccountModel payload, + }) : super(payload: payload); } class OnReadNotice extends ActionType { - @override - final String type = ReduxActions.onReadNotice; - - OnReadNotice({AccountModel payload}) : super(payload: payload); + OnReadNotice({ + @required AccountModel payload, + }) : super(payload: payload); } -class OnSkipedPremium extends ActionType { - @override - final String type = ReduxActions.onHasSkipedPremium; +class OnSendRating extends ActionType { + final int rating; - OnSkipedPremium({bool payload}) : super(payload: payload); + OnSendRating({ + @required AccountModel payload, + @required this.rating, + }) : super(payload: payload); } + +class OnSkipedPremium extends ActionType {} diff --git a/lib/redux/actions/contacts.dart b/lib/redux/actions/contacts.dart index 737ec6ef..aa2262e9 100644 --- a/lib/redux/actions/contacts.dart +++ b/lib/redux/actions/contacts.dart @@ -4,71 +4,36 @@ import 'package:tailor_made/redux/actions/main.dart'; enum SortType { recent, jobs, + completed, pending, name, reset, } -class InitContact extends ActionType> { - @override - final String type = ReduxActions.initContacts; - - InitContact({List payload}) : super(payload: payload); -} - -class AddContact extends ActionType { - @override - final String type = ReduxActions.addContact; - - AddContact({ContactModel payload}) : super(payload: payload); -} - -class RemoveContact extends ActionType { - @override - final String type = ReduxActions.removeContact; - - RemoveContact({ContactModel payload}) : super(payload: payload); -} - class SortContacts extends ActionType { - @override - final String type = ReduxActions.sortContacts; - - SortContacts({SortType payload}) : super(payload: payload); + SortContacts({ + SortType payload, + }) : super(payload: payload); } class SearchContactEvent extends ActionType { - @override - final String type = ReduxActions.onSearchContactEvent; - - SearchContactEvent({String payload}) : super(payload: payload); + SearchContactEvent({ + String payload, + }) : super(payload: payload); } class SearchSuccessContactEvent extends ActionType> { - @override - final String type = ReduxActions.onSearchSuccessContactEvent; - - SearchSuccessContactEvent({List payload}) - : super(payload: payload); -} - -class CancelSearchContactEvent extends ActionType { - @override - final String type = ReduxActions.onCancelSearchContactEvent; - - CancelSearchContactEvent({String payload}) : super(payload: payload); + SearchSuccessContactEvent({ + List payload, + }) : super(payload: payload); } -class StartSearchContactEvent extends ActionType { - @override - final String type = ReduxActions.onStartSearchContactEvent; - - StartSearchContactEvent({String payload}) : super(payload: payload); -} +class CancelSearchContactEvent extends ActionType {} -class OnDataEvent extends ActionType> { - @override - final String type = ReduxActions.onDataEventContact; +class StartSearchContactEvent extends ActionType {} - OnDataEvent({List payload}) : super(payload: payload); +class OnDataContactEvent extends ActionType> { + OnDataContactEvent({ + List payload, + }) : super(payload: payload); } diff --git a/lib/redux/actions/jobs.dart b/lib/redux/actions/jobs.dart index 41f8df25..7f1d30fd 100644 --- a/lib/redux/actions/jobs.dart +++ b/lib/redux/actions/jobs.dart @@ -11,72 +11,36 @@ enum SortType { reset, } -class InitJobs extends ActionType> { - @override - final String type = ReduxActions.initJobs; - - InitJobs({List payload}) : super(payload: payload); -} - -class AddJob extends ActionType { - @override - final String type = ReduxActions.addJob; - - AddJob({JobModel payload}) : super(payload: payload); -} - -class RemoveJob extends ActionType { - @override - final String type = ReduxActions.removeJob; - - RemoveJob({JobModel payload}) : super(payload: payload); -} - class ToggleCompleteJob extends ActionType { - @override - final String type = ReduxActions.removeJob; - - ToggleCompleteJob({JobModel payload}) : super(payload: payload); + ToggleCompleteJob({ + JobModel payload, + }) : super(payload: payload); } class SortJobs extends ActionType { - @override - final String type = ReduxActions.sortJobs; - - SortJobs({SortType payload}) : super(payload: payload); + SortJobs({ + SortType payload, + }) : super(payload: payload); } class SearchSuccessJobEvent extends ActionType> { - @override - final String type = ReduxActions.onSearchSuccessJobEvent; - - SearchSuccessJobEvent({List payload}) : super(payload: payload); + SearchSuccessJobEvent({ + List payload, + }) : super(payload: payload); } -class CancelSearchJobEvent extends ActionType { - @override - final String type = ReduxActions.onCancelSearchJobEvent; - - CancelSearchJobEvent({String payload}) : super(payload: payload); -} +class CancelSearchJobEvent extends ActionType {} -class StartSearchJobEvent extends ActionType { - @override - final String type = ReduxActions.onStartSearchJobEvent; - - StartSearchJobEvent({String payload}) : super(payload: payload); -} +class StartSearchJobEvent extends ActionType {} class SearchJobEvent extends ActionType { - @override - final String type = ReduxActions.onSearchJobEvent; - - SearchJobEvent({String payload}) : super(payload: payload); + SearchJobEvent({ + String payload, + }) : super(payload: payload); } -class OnDataEvent extends ActionType> { - @override - final String type = ReduxActions.onDataEventJob; - - OnDataEvent({List payload}) : super(payload: payload); +class OnDataJobEvent extends ActionType> { + OnDataJobEvent({ + List payload, + }) : super(payload: payload); } diff --git a/lib/redux/actions/main.dart b/lib/redux/actions/main.dart index 5004dfc3..da092ce7 100644 --- a/lib/redux/actions/main.dart +++ b/lib/redux/actions/main.dart @@ -3,72 +3,14 @@ abstract class ActionType { ActionType({this.payload}); - String get type; - @override - String toString() { - return '$runtimeType(${payload?.toString()})'; - } + String toString() => '$runtimeType(${payload?.runtimeType})'; } -class VoidAction extends ActionType { - @override - final String type = "__voidAction__"; -} +class VoidAction extends ActionType {} -class OnLogoutEvent extends ActionType { - @override - final String type = ReduxActions.onLogoutEvent; -} +class OnLogoutEvent extends ActionType {} -class InitDataEvents extends ActionType { - @override - final String type = ReduxActions.initDataEvent; -} +class InitDataEvents extends ActionType {} -class DisposeDataEvents extends ActionType { - @override - final String type = ReduxActions.disposeDataEvent; -} - -class ReduxActions { - static const String initDataEvent = "__initDataEvent__"; - static const String disposeDataEvent = "__disposeDataEvent__"; - static const String onLogoutEvent = "__onLogoutEvent__"; - - static const String initAccount = "__initAccount__"; - static const String onDataEventAccount = "__onDataEventAccount__"; - static const String onPremiumSignUp = "__onPremiumSignUp__"; - static const String onReadNotice = "__onReadNotice__"; - static const String onHasSkipedPremium = "__onHasSkipedPremium__"; - - static const String initStats = "__initStats__"; - static const String onDataEventStat = "__onDataEventStat__"; - - static const String initContacts = "__initContacts__"; - static const String addContact = "__addContact__"; - static const String removeContact = "__removeContact__"; - static const String onDataEventContact = "__onDataEventContact__"; - static const String initDataEventContact = "__initDataEventContact__"; - static const String disposeDataEventContact = "__disposeDataEventContact__"; - static const String sortContacts = "__sortContacts__"; - static const String onSearchContactEvent = "__onSearchContactEvent__"; - static const String onSearchSuccessContactEvent = - "__onSearchSuccessContactEvent__"; - static const String onStartSearchContactEvent = - "__onStartSearchContactEvent__"; - static const String onCancelSearchContactEvent = - "__onCancelSearchContactEvent__"; - - static const String initJobs = "__initJobs__"; - static const String addJob = "__addJob__"; - static const String removeJob = "__removeJob__"; - static const String onDataEventJob = "__onDataEventJob__"; - static const String initDataEventJob = "__initDataEventJob__"; - static const String disposeDataEventJob = "__disposeDataEventJob__"; - static const String sortJobs = "__sortJobs__"; - static const String onSearchJobEvent = "__onSearchJobEvent__"; - static const String onSearchSuccessJobEvent = "__onSearchSuccessJobEvent__"; - static const String onStartSearchJobEvent = "__onStartSearchJobEvent__"; - static const String onCancelSearchJobEvent = "__onCancelSearchJobEvent__"; -} +class DisposeDataEvents extends ActionType {} diff --git a/lib/redux/actions/measures.dart b/lib/redux/actions/measures.dart new file mode 100644 index 00000000..8882a284 --- /dev/null +++ b/lib/redux/actions/measures.dart @@ -0,0 +1,20 @@ +import 'package:meta/meta.dart'; +import 'package:tailor_made/models/measure.dart'; +import 'package:tailor_made/redux/actions/main.dart'; + +class OnDataMeasureEvent extends ActionType> { + Map> grouped; + + OnDataMeasureEvent({ + @required List payload, + @required this.grouped, + }) : super(payload: payload); +} + +class OnInitMeasureEvent extends ActionType> { + OnInitMeasureEvent({ + @required List payload, + }) : super(payload: payload); +} + +class ToggleMeasuresLoading extends ActionType {} diff --git a/lib/redux/actions/settings.dart b/lib/redux/actions/settings.dart new file mode 100644 index 00000000..a24b8f5f --- /dev/null +++ b/lib/redux/actions/settings.dart @@ -0,0 +1,14 @@ +import 'package:tailor_made/models/settings.dart'; +import 'package:tailor_made/redux/actions/main.dart'; + +class InitSettingsEvents extends ActionType {} + +class DisposeSettingsEvents extends ActionType {} + +class OnErrorSettingsEvents extends ActionType {} + +class OnDataSettingEvent extends ActionType { + OnDataSettingEvent({ + SettingsModel payload, + }) : super(payload: payload); +} diff --git a/lib/redux/actions/stats.dart b/lib/redux/actions/stats.dart index c7e68dd5..ea5a403e 100644 --- a/lib/redux/actions/stats.dart +++ b/lib/redux/actions/stats.dart @@ -1,16 +1,8 @@ import 'package:tailor_made/models/stats.dart'; import 'package:tailor_made/redux/actions/main.dart'; -class InitStats extends ActionType { - @override - final String type = ReduxActions.initStats; - - InitStats({StatsModel payload}) : super(payload: payload); -} - -class OnDataEvent extends ActionType { - @override - final String type = ReduxActions.onDataEventStat; - - OnDataEvent({StatsModel payload}) : super(payload: payload); +class OnDataStatEvent extends ActionType { + OnDataStatEvent({ + StatsModel payload, + }) : super(payload: payload); } diff --git a/lib/redux/epics/account.dart b/lib/redux/epics/account.dart index 3bc3db73..2ffaeb20 100644 --- a/lib/redux/epics/account.dart +++ b/lib/redux/epics/account.dart @@ -10,49 +10,81 @@ import 'package:tailor_made/redux/states/main.dart'; import 'package:tailor_made/services/cloud_db.dart'; import 'package:tailor_made/services/settings.dart'; -Stream account(Stream actions, EpicStore store) { +Stream account( + Stream actions, + EpicStore store, +) { return new Observable(actions) .ofType(new TypeToken()) - .switchMap((InitDataEvents action) => _getAccount() - .map((account) => new OnDataEvent(payload: account)) - .takeUntil( - actions.where((dynamic action) => action is DisposeDataEvents))); + .switchMap( + (InitDataEvents action) => _getAccount() + .map((account) => new OnDataAccountEvent(payload: account)) + .takeUntil( + actions.where((dynamic action) => action is DisposeDataEvents)), + ); } Stream onPremiumSignUp( - Stream actions, EpicStore store) { + Stream actions, + EpicStore store, +) { return new Observable(actions) .ofType(new TypeToken()) - .switchMap((OnPremiumSignUp action) => - Observable.fromFuture(_signUp(action.payload).catchError( - (dynamic e) => print(e), - )) - .map((account) => new VoidAction()) - // - .takeUntil( - actions.where((dynamic action) => action is DisposeDataEvents), - )); + .switchMap( + (OnPremiumSignUp action) => + Observable.fromFuture(_signUp(action.payload).catchError( + (dynamic e) => print(e), + )).map((account) => new VoidAction()).take(1), + ); +} + +Stream onSendRating( + Stream actions, + EpicStore store, +) { + return new Observable(actions) + .ofType(new TypeToken()) + .switchMap( + (OnSendRating action) => Observable.fromFuture( + _sendRating(action.payload, action.rating).catchError( + (dynamic e) => print(e), + ), + ).map((account) => new VoidAction()).take(1), + ); } Stream onReadNotice( - Stream actions, EpicStore store) { + Stream actions, + EpicStore store, +) { return new Observable(actions) .ofType(new TypeToken()) - .switchMap((OnReadNotice action) => Observable.fromFuture( - _readNotice(action.payload).catchError( - (dynamic e) => print(e), - ), - ) - .map((account) => new VoidAction()) - // - .takeUntil( - actions.where((dynamic action) => action is DisposeDataEvents), - )); + .switchMap( + (OnReadNotice action) => Observable.fromFuture( + _readNotice(action.payload).catchError( + (dynamic e) => print(e), + ), + ).map((account) => new VoidAction()).take(1), + ); } Observable _getAccount() { - return new Observable(CloudDb.account.snapshots()) - .map((DocumentSnapshot snapshot) => AccountModel.fromDoc(snapshot)); + return new Observable(CloudDb.account.snapshots()).map( + (DocumentSnapshot snapshot) => AccountModel.fromDoc(snapshot), + ); +} + +Future _sendRating(AccountModel account, int rating) async { + final _account = account.copyWith( + hasSendRating: true, + rating: rating, + ); + try { + await account.reference.updateData(_account.toMap()); + } catch (e) { + rethrow; + } + return _account; } Future _readNotice(AccountModel account) async { diff --git a/lib/redux/epics/contacts.dart b/lib/redux/epics/contacts.dart index 54729555..65066677 100644 --- a/lib/redux/epics/contacts.dart +++ b/lib/redux/epics/contacts.dart @@ -9,16 +9,27 @@ import 'package:tailor_made/redux/actions/main.dart'; import 'package:tailor_made/redux/states/main.dart'; import 'package:tailor_made/services/cloud_db.dart'; -Stream contacts(Stream actions, EpicStore store) { +Stream contacts( + Stream actions, + EpicStore store, +) { return new Observable(actions) .ofType(new TypeToken()) - .switchMap((InitDataEvents action) => _getContactList() - .map((contacts) => new OnDataEvent(payload: contacts)) - .takeUntil( - actions.where((dynamic action) => action is DisposeDataEvents))); + .switchMap( + (InitDataEvents action) => _getContactList() + .map( + (contacts) => new OnDataContactEvent(payload: contacts), + ) + .takeUntil( + actions.where((dynamic action) => action is DisposeDataEvents), + ), + ); } -Stream search(Stream actions, EpicStore store) { +Stream search( + Stream actions, + EpicStore store, +) { return new Observable(actions) .ofType(new TypeToken()) .map((action) => action.payload) @@ -36,30 +47,28 @@ Stream search(Stream actions, EpicStore store) { ), new Duration(seconds: 1), ) - ]).takeUntil( - actions.where( - (dynamic action) => action is CancelSearchContactEvent), - ), + ]).takeUntil(actions + .where((dynamic action) => action is CancelSearchContactEvent)), ); } SearchSuccessContactEvent _doSearch(List contacts, String text) { return SearchSuccessContactEvent( payload: contacts - .where( - (contact) => contact.fullname - .contains(new RegExp(r'' + text + '', caseSensitive: false)), - ) + .where((contact) => contact.fullname.contains( + new RegExp(r'' + text + '', caseSensitive: false), + )) .toList(), ); } Observable> _getContactList() { - return new Observable(CloudDb.contacts.snapshots()) - .map((QuerySnapshot snapshot) { - return snapshot.documents - .where((doc) => doc.data.containsKey('fullname')) - .map((item) => ContactModel.fromDoc(item)) - .toList(); - }); + return new Observable(CloudDb.contacts.snapshots()).map( + (QuerySnapshot snapshot) { + return snapshot.documents + .where((doc) => doc.data.containsKey('fullname')) + .map((item) => ContactModel.fromDoc(item)) + .toList(); + }, + ); } diff --git a/lib/redux/epics/jobs.dart b/lib/redux/epics/jobs.dart index 20a3563d..340446f4 100644 --- a/lib/redux/epics/jobs.dart +++ b/lib/redux/epics/jobs.dart @@ -9,13 +9,18 @@ import 'package:tailor_made/redux/actions/main.dart'; import 'package:tailor_made/redux/states/main.dart'; import 'package:tailor_made/services/cloud_db.dart'; -Stream jobs(Stream actions, EpicStore store) { +Stream jobs( + Stream actions, + EpicStore store, +) { return new Observable(actions) .ofType(new TypeToken()) - .switchMap((InitDataEvents action) => _getJobList() - .map((jobs) => new OnDataEvent(payload: jobs)) - .takeUntil( - actions.where((dynamic action) => action is DisposeDataEvents))); + .switchMap( + (InitDataEvents action) => _getJobList() + .map((jobs) => new OnDataJobEvent(payload: jobs)) + .takeUntil( + actions.where((dynamic action) => action is DisposeDataEvents)), + ); } Stream search(Stream actions, EpicStore store) { @@ -45,10 +50,9 @@ Stream search(Stream actions, EpicStore store) { SearchSuccessJobEvent _doSearch(List jobs, String text) { return new SearchSuccessJobEvent( payload: jobs - .where( - (job) => job.name - .contains(new RegExp(r'' + text + '', caseSensitive: false)), - ) + .where((job) => job.name.contains( + new RegExp(r'' + text + '', caseSensitive: false), + )) .toList(), ); } diff --git a/lib/redux/epics/main.dart b/lib/redux/epics/main.dart index 474a5802..2e5e83b5 100644 --- a/lib/redux/epics/main.dart +++ b/lib/redux/epics/main.dart @@ -1,9 +1,11 @@ import 'package:redux_epics/redux_epics.dart'; -import 'package:tailor_made/redux/states/main.dart'; import 'package:tailor_made/redux/epics/account.dart' as account; import 'package:tailor_made/redux/epics/contacts.dart' as contacts; import 'package:tailor_made/redux/epics/jobs.dart' as jobs; +import 'package:tailor_made/redux/epics/measures.dart' as measures; +import 'package:tailor_made/redux/epics/settings.dart' as settings; import 'package:tailor_made/redux/epics/stats.dart' as stats; +import 'package:tailor_made/redux/states/main.dart'; EpicMiddleware reduxEpics() => new EpicMiddleware( combineEpics( @@ -13,7 +15,11 @@ EpicMiddleware reduxEpics() => new EpicMiddleware( jobs.jobs, jobs.search, stats.stats, + measures.init, + measures.measures, + settings.settings, account.account, + account.onSendRating, account.onPremiumSignUp, account.onReadNotice, ], diff --git a/lib/redux/epics/measures.dart b/lib/redux/epics/measures.dart new file mode 100644 index 00000000..60dd0a6c --- /dev/null +++ b/lib/redux/epics/measures.dart @@ -0,0 +1,85 @@ +import 'dart:async'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:redux_epics/redux_epics.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:tailor_made/models/measure.dart'; +import 'package:tailor_made/redux/actions/main.dart'; +import 'package:tailor_made/redux/actions/measures.dart'; +import 'package:tailor_made/redux/states/main.dart'; +import 'package:tailor_made/services/cloud_db.dart'; +import 'package:tailor_made/utils/tm_group_model_by.dart'; + +Stream measures( + Stream actions, + EpicStore store, +) { + return new Observable(actions) + .ofType(new TypeToken()) + .switchMap( + (InitDataEvents action) => _getMeasures().map( + (measures) { + if (measures.isEmpty) { + return OnInitMeasureEvent( + payload: createDefaultMeasures(), + ); + } + + final grouped = groupModelBy(measures, "group"); + + return new OnDataMeasureEvent( + payload: measures, + grouped: grouped, + ); + }, + ).takeUntil( + actions.where((dynamic action) => action is DisposeDataEvents), + ), + ); +} + +Stream init( + Stream actions, + EpicStore store, +) { + return new Observable(actions) + .ofType(new TypeToken()) + .switchMap( + (OnInitMeasureEvent action) => Observable.fromFuture( + _init(action.payload).catchError( + (dynamic e) => print(e), + ), + ) + // TODO refactor need to InitDataEvent + .map((measures) => new InitDataEvents()) + .take(1), + ); +} + +Future _init(List measures) async { + final WriteBatch batch = CloudDb.instance.batch(); + + measures.forEach((measure) { + batch.setData( + CloudDb.measurements.document(measure.id), + measure.toMap(), + merge: true, + ); + }); + + try { + await batch.commit(); + } catch (e) { + rethrow; + } +} + +Observable> _getMeasures() { + return new Observable(CloudDb.measurements.snapshots()).map( + (QuerySnapshot snapshot) { + return snapshot.documents + .map((item) => MeasureModel.fromDoc(item)) + .toList(); + }, + ); +} diff --git a/lib/redux/epics/settings.dart b/lib/redux/epics/settings.dart new file mode 100644 index 00000000..6bca2a90 --- /dev/null +++ b/lib/redux/epics/settings.dart @@ -0,0 +1,44 @@ +import 'dart:async'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:redux_epics/redux_epics.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:tailor_made/models/settings.dart'; +import 'package:tailor_made/redux/actions/settings.dart'; +import 'package:tailor_made/redux/states/main.dart'; +import 'package:tailor_made/services/cloud_db.dart'; +import 'package:tailor_made/services/settings.dart'; + +Stream settings( + Stream actions, + EpicStore store, +) { + return new Observable(actions) + .ofType(new TypeToken()) + .switchMap( + (InitSettingsEvents action) => _getSettings() + .map( + (settings) { + // Keep Static copy + Settings.setData(settings); + return new OnDataSettingEvent(payload: settings); + }, + ) + .onErrorReturn(new OnErrorSettingsEvents()) + .takeUntil( + actions + .where((dynamic action) => action is DisposeSettingsEvents), + ), + ); +} + +Observable _getSettings() { + return new Observable(CloudDb.settings.snapshots()).map( + (DocumentSnapshot snapshot) { + if (snapshot.data == null) { + throw FormatException("Internet Error"); + } + return SettingsModel.fromJson(snapshot.data); + }, + ); +} diff --git a/lib/redux/epics/stats.dart b/lib/redux/epics/stats.dart index 6fa6a7c9..e40c19d8 100644 --- a/lib/redux/epics/stats.dart +++ b/lib/redux/epics/stats.dart @@ -9,16 +9,22 @@ import 'package:tailor_made/redux/actions/stats.dart'; import 'package:tailor_made/redux/states/main.dart'; import 'package:tailor_made/services/cloud_db.dart'; -Stream stats(Stream actions, EpicStore store) { +Stream stats( + Stream actions, + EpicStore store, +) { return new Observable(actions) .ofType(new TypeToken()) - .switchMap((InitDataEvents action) => _getStats() - .map((stats) => new OnDataEvent(payload: stats)) - .takeUntil( - actions.where((dynamic action) => action is DisposeDataEvents))); + .switchMap( + (InitDataEvents action) => _getStats() + .map((stats) => new OnDataStatEvent(payload: stats)) + .takeUntil( + actions.where((dynamic action) => action is DisposeDataEvents)), + ); } Observable _getStats() { - return new Observable(CloudDb.stats.snapshots()) - .map((DocumentSnapshot snapshot) => StatsModel.fromJson(snapshot.data)); + return new Observable(CloudDb.stats.snapshots()).map( + (DocumentSnapshot snapshot) => StatsModel.fromJson(snapshot.data), + ); } diff --git a/lib/redux/reducers/account.dart b/lib/redux/reducers/account.dart index 0e37c90f..c16a0aac 100644 --- a/lib/redux/reducers/account.dart +++ b/lib/redux/reducers/account.dart @@ -1,27 +1,20 @@ +import 'package:tailor_made/redux/actions/account.dart'; import 'package:tailor_made/redux/actions/main.dart'; import 'package:tailor_made/redux/states/account.dart'; -import 'package:tailor_made/redux/states/main.dart'; -AccountState reducer(ReduxState state, ActionType action) { - final AccountState account = state.account; - - switch (action.type) { - case ReduxActions.initAccount: - case ReduxActions.onDataEventAccount: - return account.copyWith( - account: action.payload, - status: AccountStatus.success, - ); - - case ReduxActions.onHasSkipedPremium: - return account.copyWith( - hasSkipedPremium: action.payload, - ); - - case ReduxActions.onLogoutEvent: - return AccountState.initialState(); +AccountState reducer(AccountState account, ActionType action) { + if (action is OnDataAccountEvent) { + return account.copyWith( + account: action.payload, + status: AccountStatus.success, + ); + } - default: - return account; + if (action is OnSkipedPremium) { + return account.copyWith( + hasSkipedPremium: true, + ); } + + return account; } diff --git a/lib/redux/reducers/contacts.dart b/lib/redux/reducers/contacts.dart index 92f708b0..a1fbe571 100644 --- a/lib/redux/reducers/contacts.dart +++ b/lib/redux/reducers/contacts.dart @@ -2,85 +2,67 @@ import 'package:tailor_made/models/contact.dart'; import 'package:tailor_made/redux/actions/contacts.dart'; import 'package:tailor_made/redux/actions/main.dart'; import 'package:tailor_made/redux/states/contacts.dart'; -import 'package:tailor_made/redux/states/main.dart'; -List _sort(List _contacts, SortType sortType) { +Comparator _sort(SortType sortType) { switch (sortType) { case SortType.jobs: - _contacts.sort((a, b) => b.totalJobs.compareTo(a.totalJobs)); - break; + return (a, b) => b.totalJobs.compareTo(a.totalJobs); case SortType.name: - _contacts.sort((a, b) => a.fullname.compareTo(b.fullname)); - break; + return (a, b) => a.fullname.compareTo(b.fullname); + case SortType.completed: + return (a, b) => + (b.totalJobs - b.pendingJobs).compareTo(a.totalJobs - a.pendingJobs); case SortType.pending: - _contacts.sort((a, b) => b.pendingJobs.compareTo(a.pendingJobs)); - break; + return (a, b) => b.pendingJobs.compareTo(a.pendingJobs); case SortType.recent: - _contacts.sort((a, b) => b.createdAt.compareTo(a.createdAt)); - break; + return (a, b) => b.createdAt.compareTo(a.createdAt); case SortType.reset: default: - _contacts.sort((a, b) => a.id.compareTo(b.id)); - break; + return (a, b) => a.id.compareTo(b.id); } - return _contacts; } -ContactsState reducer(ReduxState state, ActionType action) { - final ContactsState contacts = state.contacts; - - switch (action.type) { - case ReduxActions.initContacts: - case ReduxActions.onDataEventContact: - return contacts.copyWith( - contacts: _sort(action.payload, contacts.sortFn), - status: ContactsStatus.success, - ); - - case ReduxActions.onStartSearchContactEvent: - return contacts.copyWith( - status: ContactsStatus.loading, - isSearching: true, - ); - - case ReduxActions.onCancelSearchContactEvent: - return contacts.copyWith( - status: ContactsStatus.success, - isSearching: false, - searchResults: [], - ); - - case ReduxActions.onSearchSuccessContactEvent: - return contacts.copyWith( - searchResults: _sort(action.payload, contacts.sortFn), - status: ContactsStatus.success, - ); - - case ReduxActions.sortContacts: - final _contacts = contacts.contacts; - return contacts.copyWith( - contacts: _sort(_contacts, action.payload), - hasSortFn: action.payload != SortType.reset, - sortFn: action.payload, - status: ContactsStatus.success, - ); +ContactsState reducer(ContactsState contacts, ActionType action) { + if (action is OnDataContactEvent) { + return contacts.copyWith( + contacts: List.of(action.payload) + ..sort(_sort(contacts.sortFn)), + status: ContactsStatus.success, + ); + } - case ReduxActions.onLogoutEvent: - return ContactsState.initialState(); + if (action is StartSearchContactEvent) { + return contacts.copyWith( + status: ContactsStatus.loading, + isSearching: true, + ); + } - // case ReduxActions.addContact: - // List _contacts = new List.from(contacts.contacts)..add(action.payload); - // return contacts.copyWith(contacts: _contacts); + if (action is SearchSuccessContactEvent) { + return contacts.copyWith( + searchResults: List.of(action.payload) + ..sort(_sort(contacts.sortFn)), + status: ContactsStatus.success, + ); + } - // case ReduxActions.removeContact: - // List _contacts = contacts.contacts - // .where( - // (contact) => contact.id != action.payload.id, - // ) - // .toList(); - // return contacts.copyWith(contacts: _contacts); + if (action is SortContacts) { + return contacts.copyWith( + contacts: List.of(contacts.contacts) + ..sort(_sort(action.payload)), + hasSortFn: action.payload != SortType.reset, + sortFn: action.payload, + status: ContactsStatus.success, + ); + } - default: - return contacts; + if (action is CancelSearchContactEvent) { + return contacts.copyWith( + status: ContactsStatus.success, + isSearching: false, + searchResults: [], + ); } + + return contacts; } diff --git a/lib/redux/reducers/jobs.dart b/lib/redux/reducers/jobs.dart index cd35f9db..222359df 100644 --- a/lib/redux/reducers/jobs.dart +++ b/lib/redux/reducers/jobs.dart @@ -3,98 +3,71 @@ import 'package:tailor_made/models/payment.dart'; import 'package:tailor_made/redux/actions/jobs.dart'; import 'package:tailor_made/redux/actions/main.dart'; import 'package:tailor_made/redux/states/jobs.dart'; -import 'package:tailor_made/redux/states/main.dart'; -List _sort(List _jobs, SortType sortType) { +final _foldPrice = (double acc, PaymentModel model) => acc + model.price; + +Comparator _sort(SortType sortType) { switch (sortType) { case SortType.active: - _jobs.sort( - (a, b) => (a.isComplete == b.isComplete) ? 0 : a.isComplete ? -1 : 1); - break; + return (a, b) => + (a.isComplete == b.isComplete) ? 0 : a.isComplete ? 1 : -1; case SortType.name: - _jobs.sort((a, b) => a.name.compareTo(b.name)); - break; + return (a, b) => a.name.compareTo(b.name); case SortType.payments: - final _folder = - (double value, PaymentModel element) => value + element.price; - _jobs.sort( - (a, b) => b.payments.fold(0.0, _folder).compareTo( - a.payments.fold(0.0, _folder), - ), - ); - break; + return (a, b) => b.payments.fold(0.0, _foldPrice).compareTo( + a.payments.fold(0.0, _foldPrice), + ); case SortType.owed: - _jobs.sort((a, b) => b.pendingPayment.compareTo(a.pendingPayment)); - break; + return (a, b) => b.pendingPayment.compareTo(a.pendingPayment); case SortType.price: - _jobs.sort((a, b) => b.price.compareTo(a.price)); - break; + return (a, b) => b.price.compareTo(a.price); case SortType.recent: - _jobs.sort((a, b) => b.createdAt.compareTo(a.createdAt)); - break; + return (a, b) => b.createdAt.compareTo(a.createdAt); case SortType.reset: default: - _jobs.sort((a, b) => a.id.compareTo(b.id)); - break; + return (a, b) => a.id.compareTo(b.id); } - return _jobs; } -JobsState reducer(ReduxState state, ActionType action) { - final JobsState jobs = state.jobs; - - switch (action.type) { - case ReduxActions.initJobs: - case ReduxActions.onDataEventJob: - return jobs.copyWith( - jobs: _sort(action.payload, jobs.sortFn), - status: JobsStatus.success, - ); - - case ReduxActions.onStartSearchJobEvent: - return jobs.copyWith( - status: JobsStatus.loading, - isSearching: true, - ); - - case ReduxActions.onCancelSearchJobEvent: - return jobs.copyWith( - status: JobsStatus.success, - isSearching: false, - searchResults: [], - ); - - case ReduxActions.onSearchSuccessJobEvent: - return jobs.copyWith( - searchResults: _sort(action.payload, jobs.sortFn), - status: JobsStatus.success, - ); - - case ReduxActions.sortJobs: - final _jobs = jobs.jobs; - return jobs.copyWith( - jobs: _sort(_jobs, action.payload), - hasSortFn: action.payload != SortType.reset, - sortFn: action.payload, - status: JobsStatus.success, - ); +JobsState reducer(JobsState jobs, ActionType action) { + if (action is OnDataJobEvent) { + return jobs.copyWith( + jobs: List.of(action.payload)..sort(_sort(jobs.sortFn)), + status: JobsStatus.success, + ); + } - case ReduxActions.onLogoutEvent: - return JobsState.initialState(); + if (action is StartSearchJobEvent) { + return jobs.copyWith( + status: JobsStatus.loading, + isSearching: true, + ); + } - // case ReduxActions.addJob: - // List _jobs = new List.from(jobs.jobs)..add(action.payload); - // return jobs.copyWith(jobs: _jobs); + if (action is CancelSearchJobEvent) { + return jobs.copyWith( + status: JobsStatus.success, + isSearching: false, + searchResults: [], + ); + } - // case ReduxActions.removeJob: - // List _jobs = jobs.jobs - // .where( - // (job) => job.id != action.payload.id, - // ) - // .toList(); - // return jobs.copyWith(jobs: _jobs); + if (action is SearchSuccessJobEvent) { + return jobs.copyWith( + searchResults: List.of(action.payload) + ..sort(_sort(jobs.sortFn)), + status: JobsStatus.success, + ); + } - default: - return jobs; + if (action is SortJobs) { + return jobs.copyWith( + jobs: List.of(jobs.jobs)..sort(_sort(action.payload)), + hasSortFn: action.payload != SortType.reset, + sortFn: action.payload, + status: JobsStatus.success, + ); } + + return jobs; } diff --git a/lib/redux/reducers/main.dart b/lib/redux/reducers/main.dart index be5e152e..a50cabec 100644 --- a/lib/redux/reducers/main.dart +++ b/lib/redux/reducers/main.dart @@ -1,12 +1,23 @@ +import 'package:tailor_made/redux/actions/main.dart'; import 'package:tailor_made/redux/reducers/account.dart' as account; import 'package:tailor_made/redux/reducers/contacts.dart' as contacts; import 'package:tailor_made/redux/reducers/jobs.dart' as jobs; +import 'package:tailor_made/redux/reducers/measures.dart' as measures; +import 'package:tailor_made/redux/reducers/settings.dart' as settings; import 'package:tailor_made/redux/reducers/stats.dart' as stats; import 'package:tailor_made/redux/states/main.dart'; -ReduxState reduxReducer(ReduxState state, dynamic action) => new ReduxState( - contacts: contacts.reducer(state, action), - jobs: jobs.reducer(state, action), - stats: stats.reducer(state, action), - account: account.reducer(state, action), - ); +ReduxState reduxReducer(ReduxState state, dynamic action) { + if (action is OnLogoutEvent) { + return ReduxState.initialState(); + } + + return new ReduxState( + contacts: contacts.reducer(state.contacts, action), + jobs: jobs.reducer(state.jobs, action), + stats: stats.reducer(state.stats, action), + account: account.reducer(state.account, action), + measures: measures.reducer(state.measures, action), + settings: settings.reducer(state.settings, action), + ); +} diff --git a/lib/redux/reducers/measures.dart b/lib/redux/reducers/measures.dart new file mode 100644 index 00000000..696f8cc0 --- /dev/null +++ b/lib/redux/reducers/measures.dart @@ -0,0 +1,21 @@ +import 'package:tailor_made/redux/actions/main.dart'; +import 'package:tailor_made/redux/actions/measures.dart'; +import 'package:tailor_made/redux/states/measures.dart'; + +MeasuresState reducer(MeasuresState measures, ActionType action) { + if (action is OnDataMeasureEvent) { + return measures.copyWith( + measures: action.payload..sort((a, b) => a.group.compareTo(b.group)), + grouped: action.grouped, + status: MeasuresStatus.success, + ); + } + + if (action is ToggleMeasuresLoading || action is OnInitMeasureEvent) { + return measures.copyWith( + status: MeasuresStatus.loading, + ); + } + + return measures; +} diff --git a/lib/redux/reducers/settings.dart b/lib/redux/reducers/settings.dart new file mode 100644 index 00000000..ee07c0c6 --- /dev/null +++ b/lib/redux/reducers/settings.dart @@ -0,0 +1,26 @@ +import 'package:tailor_made/redux/actions/main.dart'; +import 'package:tailor_made/redux/actions/settings.dart'; +import 'package:tailor_made/redux/states/settings.dart'; + +SettingsState reducer(SettingsState settings, ActionType action) { + if (action is InitSettingsEvents) { + return settings.copyWith( + status: SettingsStatus.loading, + ); + } + + if (action is OnDataSettingEvent) { + return settings.copyWith( + settings: action.payload, + status: SettingsStatus.success, + ); + } + + if (action is OnErrorSettingsEvents) { + return settings.copyWith( + status: SettingsStatus.failure, + ); + } + + return settings; +} diff --git a/lib/redux/reducers/stats.dart b/lib/redux/reducers/stats.dart index f00469fa..4bd7332f 100644 --- a/lib/redux/reducers/stats.dart +++ b/lib/redux/reducers/stats.dart @@ -1,22 +1,14 @@ import 'package:tailor_made/redux/actions/main.dart'; -import 'package:tailor_made/redux/states/main.dart'; +import 'package:tailor_made/redux/actions/stats.dart'; import 'package:tailor_made/redux/states/stats.dart'; -StatsState reducer(ReduxState state, ActionType action) { - final StatsState stats = state.stats; - - switch (action.type) { - case ReduxActions.initStats: - case ReduxActions.onDataEventStat: - return stats.copyWith( - stats: action.payload, - status: StatsStatus.success, - ); - - case ReduxActions.onLogoutEvent: - return StatsState.initialState(); - - default: - return stats; +StatsState reducer(StatsState stats, ActionType action) { + if (action is OnDataStatEvent) { + return stats.copyWith( + stats: action.payload, + status: StatsStatus.success, + ); } + + return stats; } diff --git a/lib/redux/states/jobs.dart b/lib/redux/states/jobs.dart index d5af5ce5..7db873fd 100644 --- a/lib/redux/states/jobs.dart +++ b/lib/redux/states/jobs.dart @@ -31,8 +31,8 @@ class JobsState { const JobsState.initialState() : jobs = null, status = JobsStatus.loading, - hasSortFn = false, - sortFn = SortType.reset, + hasSortFn = true, + sortFn = SortType.active, searchResults = null, isSearching = false, message = ''; diff --git a/lib/redux/states/main.dart b/lib/redux/states/main.dart index 8476c512..0eaf4643 100644 --- a/lib/redux/states/main.dart +++ b/lib/redux/states/main.dart @@ -3,6 +3,8 @@ import 'package:flutter/widgets.dart'; import 'package:tailor_made/redux/states/account.dart'; import 'package:tailor_made/redux/states/contacts.dart'; import 'package:tailor_made/redux/states/jobs.dart'; +import 'package:tailor_made/redux/states/measures.dart'; +import 'package:tailor_made/redux/states/settings.dart'; import 'package:tailor_made/redux/states/stats.dart'; @immutable @@ -11,18 +13,24 @@ class ReduxState { final JobsState jobs; final StatsState stats; final AccountState account; + final MeasuresState measures; + final SettingsState settings; const ReduxState({ @required this.contacts, @required this.jobs, @required this.stats, @required this.account, + @required this.measures, + @required this.settings, }); const ReduxState.initialState() : contacts = const ContactsState.initialState(), jobs = const JobsState.initialState(), account = const AccountState.initialState(), + measures = const MeasuresState.initialState(), + settings = const SettingsState.initialState(), stats = const StatsState.initialState(); ReduxState copyWith({ @@ -30,12 +38,16 @@ class ReduxState { JobsState jobs, StatsState stats, AccountState account, + MeasuresState measures, + SettingsState settings, }) { return new ReduxState( contacts: contacts ?? this.contacts, jobs: jobs ?? this.jobs, stats: stats ?? this.stats, account: account ?? this.account, + measures: measures ?? this.measures, + settings: settings ?? this.settings, ); } } diff --git a/lib/redux/states/measures.dart b/lib/redux/states/measures.dart new file mode 100644 index 00000000..4b3c4f88 --- /dev/null +++ b/lib/redux/states/measures.dart @@ -0,0 +1,48 @@ +import 'package:flutter/foundation.dart'; +import 'package:tailor_made/models/measure.dart'; + +enum MeasuresStatus { + loading, + success, + failure, +} + +@immutable +class MeasuresState { + final List measures; + final Map> grouped; + final MeasuresStatus status; + final bool hasSkipedPremium; + final String message; + + const MeasuresState({ + @required this.measures, + @required this.grouped, + @required this.status, + @required this.hasSkipedPremium, + @required this.message, + }); + + const MeasuresState.initialState() + : measures = null, + grouped = null, + status = MeasuresStatus.loading, + hasSkipedPremium = false, + message = ''; + + MeasuresState copyWith({ + List measures, + Map> grouped, + MeasuresStatus status, + bool hasSkipedPremium, + String message, + }) { + return new MeasuresState( + measures: measures ?? this.measures, + grouped: grouped ?? this.grouped, + status: status ?? this.status, + hasSkipedPremium: hasSkipedPremium ?? this.hasSkipedPremium, + message: message ?? this.message, + ); + } +} diff --git a/lib/redux/states/settings.dart b/lib/redux/states/settings.dart new file mode 100644 index 00000000..96b7e1c1 --- /dev/null +++ b/lib/redux/states/settings.dart @@ -0,0 +1,38 @@ +import 'package:flutter/foundation.dart'; +import 'package:tailor_made/models/settings.dart'; + +enum SettingsStatus { + loading, + success, + failure, +} + +@immutable +class SettingsState { + final SettingsModel settings; + final SettingsStatus status; + final String message; + + const SettingsState({ + @required this.settings, + @required this.status, + @required this.message, + }); + + const SettingsState.initialState() + : settings = null, + status = SettingsStatus.loading, + message = ''; + + SettingsState copyWith({ + SettingsModel settings, + SettingsStatus status, + String message, + }) { + return new SettingsState( + settings: settings ?? this.settings, + status: status ?? this.status, + message: message ?? this.message, + ); + } +} diff --git a/lib/redux/view_models/account.dart b/lib/redux/view_models/account.dart index d63376fa..a208829d 100644 --- a/lib/redux/view_models/account.dart +++ b/lib/redux/view_models/account.dart @@ -7,11 +7,13 @@ import 'package:tailor_made/redux/view_models/main.dart'; class AccountViewModel extends ViewModel { AccountViewModel(Store store) : super(store); - AccountModel get account => store.state.account.account; + AccountState get _state => store.state.account; - bool get isLoading => store.state.account.status == AccountStatus.loading; + AccountModel get account => _state.account; - bool get isSuccess => store.state.account.status == AccountStatus.success; + bool get isLoading => _state.status == AccountStatus.loading; - bool get isFailure => store.state.account.status == AccountStatus.failure; + bool get isSuccess => _state.status == AccountStatus.success; + + bool get isFailure => _state.status == AccountStatus.failure; } diff --git a/lib/redux/view_models/contact_job.dart b/lib/redux/view_models/contact_job.dart index aea23c7d..b9a00d81 100644 --- a/lib/redux/view_models/contact_job.dart +++ b/lib/redux/view_models/contact_job.dart @@ -1,4 +1,5 @@ import 'package:redux/redux.dart'; +import 'package:tailor_made/models/account.dart'; import 'package:tailor_made/models/contact.dart'; import 'package:tailor_made/models/job.dart'; import 'package:tailor_made/redux/states/main.dart'; @@ -10,10 +11,12 @@ class ContactJobViewModel extends ViewModel { ContactJobViewModel(Store store) : super(store); + ReduxState get _state => store.state; + ContactModel get selectedContact { if (contactID != null) { try { - return store.state.contacts.contacts.firstWhere( + return _state.contacts.contacts.firstWhere( (_) => _.id == contactID, ); } catch (e) { @@ -26,7 +29,7 @@ class ContactJobViewModel extends ViewModel { JobModel get selectedJob { if (jobID != null) { try { - return store.state.jobs.jobs.firstWhere( + return _state.jobs.jobs.firstWhere( (_) => _.id == jobID, ); } catch (e) { @@ -35,4 +38,6 @@ class ContactJobViewModel extends ViewModel { } return null; } + + AccountModel get account => _state.account.account; } diff --git a/lib/redux/view_models/contacts.dart b/lib/redux/view_models/contacts.dart index 1bef114f..9f888d97 100644 --- a/lib/redux/view_models/contacts.dart +++ b/lib/redux/view_models/contacts.dart @@ -1,5 +1,7 @@ import 'package:redux/redux.dart'; import 'package:tailor_made/models/contact.dart'; +import 'package:tailor_made/models/job.dart'; +import 'package:tailor_made/models/measure.dart'; import 'package:tailor_made/redux/actions/contacts.dart'; import 'package:tailor_made/redux/states/contacts.dart'; import 'package:tailor_made/redux/states/main.dart'; @@ -16,6 +18,9 @@ class ContactsViewModel extends ViewModel { return isSearching ? _state.searchResults : _state.contacts; } + Map> get measuresGrouped => + store.state.measures.grouped; + ContactModel get selected { if (contactID != null) { try { @@ -29,6 +34,19 @@ class ContactsViewModel extends ViewModel { return null; } + List get selectedJobs { + if (selected != null) { + try { + return store.state.jobs.jobs + .where((job) => job.contactID == selected.id) + .toList(); + } catch (e) { + // + } + } + return []; + } + bool get isLoading => _state.status == ContactsStatus.loading; bool get isSuccess => _state.status == ContactsStatus.success; diff --git a/lib/redux/view_models/jobs.dart b/lib/redux/view_models/jobs.dart index 1fe0e0e7..cca92d05 100644 --- a/lib/redux/view_models/jobs.dart +++ b/lib/redux/view_models/jobs.dart @@ -7,26 +7,42 @@ import 'package:tailor_made/redux/states/main.dart'; import 'package:tailor_made/redux/view_models/main.dart'; class JobsViewModel extends ViewModel { - ContactModel contact; + String jobID; JobsViewModel(Store store) : super(store); JobsState get _state => store.state.jobs; List get jobs { - final jobs = store.state.jobs.jobs; - if (contact != null) { - return jobs.where((_) => _.contactID == contact.id).toList(); - } - return isSearching ? _state.searchResults : jobs; + return isSearching ? _state.searchResults : store.state.jobs.jobs; } List get contacts { return store.state.contacts.contacts; } - void filterByContact(ContactModel contact) { - contact = contact; + JobModel get selected { + if (jobID != null) { + try { + return _state.jobs.firstWhere( + (_) => _.id == jobID, + ); + } catch (e) { + // + } + } + return null; + } + + ContactModel get selectedContact { + if (selected != null) { + try { + return contacts.firstWhere((_) => _.id == selected.contactID); + } catch (e) { + // + } + } + return null; } void toggleCompleteJob(JobModel job) { diff --git a/lib/redux/view_models/measures.dart b/lib/redux/view_models/measures.dart new file mode 100644 index 00000000..ec379e20 --- /dev/null +++ b/lib/redux/view_models/measures.dart @@ -0,0 +1,24 @@ +import 'package:redux/redux.dart'; +import 'package:tailor_made/models/measure.dart'; +import 'package:tailor_made/redux/actions/measures.dart'; +import 'package:tailor_made/redux/states/main.dart'; +import 'package:tailor_made/redux/states/measures.dart'; +import 'package:tailor_made/redux/view_models/main.dart'; + +class MeasuresViewModel extends ViewModel { + MeasuresViewModel(Store store) : super(store); + + MeasuresState get _state => store.state.measures; + + List get measures => _state.measures; + + Map> get grouped => _state.grouped; + + bool get isLoading => _state.status == MeasuresStatus.loading; + + bool get isSuccess => _state.status == MeasuresStatus.success; + + bool get isFailure => _state.status == MeasuresStatus.failure; + + void toggleLoading() => store.dispatch(ToggleMeasuresLoading()); +} diff --git a/lib/redux/view_models/settings.dart b/lib/redux/view_models/settings.dart new file mode 100644 index 00000000..81fcbb22 --- /dev/null +++ b/lib/redux/view_models/settings.dart @@ -0,0 +1,22 @@ +import 'package:redux/redux.dart'; +import 'package:tailor_made/models/settings.dart'; +import 'package:tailor_made/redux/actions/settings.dart'; +import 'package:tailor_made/redux/states/main.dart'; +import 'package:tailor_made/redux/states/settings.dart'; +import 'package:tailor_made/redux/view_models/main.dart'; + +class SettingsViewModel extends ViewModel { + SettingsViewModel(Store store) : super(store); + + SettingsState get _state => store.state.settings; + + SettingsModel get settings => _state.settings; + + bool get isLoading => _state.status == SettingsStatus.loading; + + bool get isSuccess => _state.status == SettingsStatus.success; + + bool get isFailure => _state.status == SettingsStatus.failure; + + void init() => store.dispatch(InitSettingsEvents()); +} diff --git a/lib/redux/view_models/stats.dart b/lib/redux/view_models/stats.dart index cdb4cd58..4ae0104e 100644 --- a/lib/redux/view_models/stats.dart +++ b/lib/redux/view_models/stats.dart @@ -7,11 +7,13 @@ import 'package:tailor_made/redux/view_models/main.dart'; class StatsViewModel extends ViewModel { StatsViewModel(Store store) : super(store); - StatsModel get stats => store.state.stats.stats; + StatsState get _state => store.state.stats; - bool get isLoading => store.state.stats.status == StatsStatus.loading; + StatsModel get stats => _state.stats; - bool get isSuccess => store.state.stats.status == StatsStatus.success; + bool get isLoading => _state.status == StatsStatus.loading; - bool get isFailure => store.state.stats.status == StatsStatus.failure; + bool get isSuccess => _state.status == StatsStatus.success; + + bool get isFailure => _state.status == StatsStatus.failure; } diff --git a/lib/services/auth.dart b/lib/services/auth.dart index 754e9545..28689bb4 100644 --- a/lib/services/auth.dart +++ b/lib/services/auth.dart @@ -46,8 +46,6 @@ class Auth { setUser(user); return user; } catch (e) { - // TODO should test this when flutter fixes debug mode - print(e); rethrow; } } diff --git a/lib/services/cloud_db.dart b/lib/services/cloud_db.dart index 26cba734..2d94ad41 100644 --- a/lib/services/cloud_db.dart +++ b/lib/services/cloud_db.dart @@ -13,6 +13,9 @@ class CloudDb { static DocumentReference get stats => instance.document('stats/$authUserId'); static DocumentReference get settings => instance.document('settings/common'); + static CollectionReference get measurements => + instance.collection('measurements/$authUserId/common'); + static CollectionReference get premium => instance.collection('premium'); static Query get gallery => diff --git a/lib/services/settings.dart b/lib/services/settings.dart index c145dbd1..3025ec58 100644 --- a/lib/services/settings.dart +++ b/lib/services/settings.dart @@ -1,5 +1,4 @@ import 'package:tailor_made/models/settings.dart'; -import 'package:version/version.dart'; class Settings { static SettingsModel _settings; @@ -7,26 +6,11 @@ class Settings { Settings._(); - static void setData(Map json) { - _settings = SettingsModel.fromJson(json); - } + static void setData(SettingsModel data) => _settings = data; - static SettingsModel getData() { - return _settings; - } + static SettingsModel getData() => _settings; - static void setVersion(String version) { - _versionName = version; - } + static void setVersion(String version) => _versionName = version; - static String getVersion() { - return _versionName; - } - - static bool get isOutdated { - final currentVersion = Version.parse(getVersion()); - final latestVersion = Version.parse(getData()?.versionName ?? "1.0.0"); - - return latestVersion > currentVersion; - } + static String getVersion() => _versionName; } diff --git a/lib/ui/app_bar.dart b/lib/ui/app_bar.dart index 9a78bb01..a4d30db1 100644 --- a/lib/ui/app_bar.dart +++ b/lib/ui/app_bar.dart @@ -6,6 +6,7 @@ AppBar appBar( BuildContext context, { String title: "", List actions, + VoidCallback onPop, double elevation: 1.0, bool centerTitle: false, }) { @@ -13,7 +14,7 @@ AppBar appBar( return AppBar( elevation: elevation, backgroundColor: theme.appBarBackgroundColor, - leading: backButton(context), + leading: backButton(context, onPop: onPop), brightness: Brightness.light, centerTitle: centerTitle, title: new Text( diff --git a/lib/ui/back_button.dart b/lib/ui/back_button.dart index 69f6c566..46dfc2e6 100644 --- a/lib/ui/back_button.dart +++ b/lib/ui/back_button.dart @@ -1,13 +1,17 @@ import 'package:flutter/material.dart'; import 'package:tailor_made/utils/tm_theme.dart'; -IconButton backButton(BuildContext context, {Color color}) { +IconButton backButton( + BuildContext context, { + Color color, + VoidCallback onPop, +}) { final TMTheme theme = TMTheme.of(context); return new IconButton( icon: new Icon( Icons.arrow_back, color: color ?? theme.appBarColor, ), - onPressed: () => Navigator.maybePop(context), + onPressed: onPop ?? () => Navigator.maybePop(context), ); } diff --git a/lib/ui/loading_snackbar.dart b/lib/ui/loading_snackbar.dart index dc0425f6..bee40e55 100644 --- a/lib/ui/loading_snackbar.dart +++ b/lib/ui/loading_snackbar.dart @@ -1,24 +1,31 @@ import 'package:flutter/material.dart'; import 'package:flutter_spinkit/flutter_spinkit.dart'; +import 'package:tailor_made/utils/tm_theme.dart'; class LoadingSnackBar extends SnackBar { LoadingSnackBar({Key key, Widget content}) : super( key: key, + backgroundColor: content == null ? Colors.white : kPrimaryColor, content: Row( mainAxisAlignment: content == null ? MainAxisAlignment.center : MainAxisAlignment.start, children: [ SizedBox.fromSize( - size: Size.square(30.0), + size: Size(48.0, 24.0), child: SpinKitThreeBounce( - color: Colors.white, - size: 32.0, + color: content == null ? kPrimaryColor : Colors.white, + size: 24.0, ), ), SizedBox(width: content == null ? 0.0 : 16.0), - content ?? SizedBox(), + content == null + ? SizedBox() + : new DefaultTextStyle( + style: ralewayBold(14.0, Colors.white), + child: content, + ), ], ), duration: Duration(days: 1), diff --git a/lib/pages/jobs/ui/slide_down.dart b/lib/ui/slide_down.dart similarity index 65% rename from lib/pages/jobs/ui/slide_down.dart rename to lib/ui/slide_down.dart index e84245d8..5a148533 100644 --- a/lib/pages/jobs/ui/slide_down.dart +++ b/lib/ui/slide_down.dart @@ -7,12 +7,14 @@ class SlideDownItem extends StatefulWidget { final bool isExpanded; final String title; final Widget body; + final VoidCallback onLongPress; const SlideDownItem({ Key key, this.title, this.body, this.isExpanded: false, + this.onLongPress, }) : super(key: key); @override @@ -49,26 +51,29 @@ class SlideDownItemState extends State { final Widget header = new Material( color: Colors.white, elevation: isExpanded ? 1.0 : 0.0, - child: new InkWell( - child: new Row( - children: [ - new Expanded( - child: new Container( - padding: EdgeInsets.only(left: 16.0, top: 16.0, bottom: 16.0), - child: Text( - widget.title, - style: theme.titleStyle.copyWith(fontSize: 14.0), + child: GestureDetector( + child: new InkWell( + child: new Row( + children: [ + new Expanded( + child: new Container( + padding: EdgeInsets.only(left: 16.0, top: 16.0, bottom: 16.0), + child: Text( + widget.title, + style: theme.titleStyle.copyWith(fontSize: 14.0), + ), ), ), - ), - new Padding( - padding: const EdgeInsets.only(right: 16.0), - child: Icon( - isExpanded ? Icons.arrow_drop_up : Icons.arrow_drop_down), - ), - ], + new Padding( + padding: const EdgeInsets.only(right: 16.0), + child: Icon( + isExpanded ? Icons.arrow_drop_up : Icons.arrow_drop_down), + ), + ], + ), + onTap: () => setState(() => isExpanded = !isExpanded), ), - onTap: () => setState(() => isExpanded = !isExpanded), + onLongPress: widget.onLongPress ?? () {}, ), ); diff --git a/lib/utils/tm_confirm_dialog.dart b/lib/utils/tm_confirm_dialog.dart index e903c15a..28b883ca 100644 --- a/lib/utils/tm_confirm_dialog.dart +++ b/lib/utils/tm_confirm_dialog.dart @@ -5,18 +5,23 @@ import 'package:tailor_made/utils/tm_child_dialog.dart'; Future confirmDialog({ @required BuildContext context, - @required Widget title, + @required Widget content, + Widget title, }) => showChildDialog( context: context, child: new AlertDialog( - content: title, + title: title, + content: content, actions: [ FlatButton( - onPressed: () => Navigator.pop(context, false), - child: Text("DISMISS")), + onPressed: () => Navigator.pop(context, false), + child: Text("CANCEL"), + ), FlatButton( - onPressed: () => Navigator.pop(context, true), child: Text("OK")), + onPressed: () => Navigator.pop(context, true), + child: Text("OK"), + ), ], ), ); diff --git a/lib/utils/tm_group_model_by.dart b/lib/utils/tm_group_model_by.dart new file mode 100644 index 00000000..b0d49d55 --- /dev/null +++ b/lib/utils/tm_group_model_by.dart @@ -0,0 +1,12 @@ +// Group List by group name +import 'package:tailor_made/models/main.dart'; + +Map> groupModelBy(List list, dynamic key) { + return list.fold( + >{}, + (rv, T x) { + (rv[x[key]] = rv[x[key]] ?? []).add(x); + return rv; + }, + ); +} diff --git a/lib/utils/tm_images.dart b/lib/utils/tm_images.dart index 09a45833..7902bb57 100644 --- a/lib/utils/tm_images.dart +++ b/lib/utils/tm_images.dart @@ -7,4 +7,6 @@ class TMImages { AssetImage('assets/images/google_logo.png'); static const ImageProvider pattern = AssetImage('assets/images/pattern.png'); static const ImageProvider logo = AssetImage('assets/images/logo.png'); + static const ImageProvider verified = + AssetImage('assets/images/verified.png'); } diff --git a/lib/utils/tm_snackbar.dart b/lib/utils/tm_snackbar.dart index 67a4ae44..ffe188df 100644 --- a/lib/utils/tm_snackbar.dart +++ b/lib/utils/tm_snackbar.dart @@ -1,13 +1,24 @@ import 'package:flutter/material.dart'; import 'package:tailor_made/ui/loading_snackbar.dart'; +import 'package:tailor_made/utils/tm_theme.dart'; abstract class SnackBarProvider { GlobalKey get scaffoldKey; - void showInSnackBar(String value, - [Duration duration = const Duration(milliseconds: 1500)]) { + void showInSnackBar( + String value, [ + Duration duration = const Duration(milliseconds: 4000), + ]) { scaffoldKey.currentState?.showSnackBar( - new SnackBar(content: new Text(value), duration: duration)); + new SnackBar( + backgroundColor: kPrimaryColor, + content: new Text( + value, + style: ralewayMedium(14.0, Colors.white), + ), + duration: duration, + ), + ); } void closeLoadingSnackBar() { diff --git a/pubspec.yaml b/pubspec.yaml index d6a42216..54f25de7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: tailor_made description: Tailor-Made with love. -version: 1.0.3 +version: 1.1.0 dependencies: flutter: