From ee470f6eff4da1d12ea419ddc105c2f667e6ad29 Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Thu, 13 Dec 2018 13:21:56 +0200 Subject: [PATCH] Projects --- lib/data/models/serializers.dart | 5 + lib/data/models/serializers.g.dart | 4 + lib/data/repositories/project_repository.dart | 69 +++ lib/main.dart | 15 + lib/redux/app/app_state.dart | 11 +- lib/redux/company/company_reducer.dart | 4 + lib/redux/company/company_state.dart | 6 + lib/redux/company/company_state.g.dart | 37 +- lib/redux/project/project_actions.dart | 226 ++++++++++ lib/redux/project/project_middleware.dart | 231 +++++++++++ lib/redux/project/project_reducer.dart | 197 +++++++++ lib/redux/project/project_selectors.dart | 60 +++ lib/redux/project/project_state.dart | 59 +++ lib/redux/project/project_state.g.dart | 392 ++++++++++++++++++ lib/redux/ui/ui_reducer.dart | 4 + lib/redux/ui/ui_state.dart | 6 + lib/redux/ui/ui_state.g.dart | 51 ++- lib/ui/app/app_drawer.dart | 8 + lib/ui/project/edit/project_edit.dart | 112 +++++ lib/ui/project/edit/project_edit_vm.dart | 84 ++++ lib/ui/project/project_list.dart | 119 ++++++ lib/ui/project/project_list_item.dart | 84 ++++ lib/ui/project/project_list_vm.dart | 135 ++++++ lib/ui/project/project_screen.dart | 88 ++++ lib/ui/project/view/project_view.dart | 52 +++ lib/ui/project/view/project_view_vm.dart | 82 ++++ lib/utils/localization.dart | 3 + stubs/data/repositories/stub_repository | 1 + stubs/redux/stub/stub_reducer | 4 +- stubs/ui/stub/edit/stub_edit | 28 +- stubs/ui/stub/edit/stub_edit_vm | 7 +- stubs/ui/stub/stub_list | 9 +- stubs/ui/stub/stub_list_item | 8 +- 33 files changed, 2162 insertions(+), 39 deletions(-) create mode 100644 lib/data/repositories/project_repository.dart create mode 100644 lib/redux/project/project_actions.dart create mode 100644 lib/redux/project/project_middleware.dart create mode 100644 lib/redux/project/project_reducer.dart create mode 100644 lib/redux/project/project_selectors.dart create mode 100644 lib/redux/project/project_state.dart create mode 100644 lib/redux/project/project_state.g.dart create mode 100644 lib/ui/project/edit/project_edit.dart create mode 100644 lib/ui/project/edit/project_edit_vm.dart create mode 100644 lib/ui/project/project_list.dart create mode 100644 lib/ui/project/project_list_item.dart create mode 100644 lib/ui/project/project_list_vm.dart create mode 100644 lib/ui/project/project_screen.dart create mode 100644 lib/ui/project/view/project_view.dart create mode 100644 lib/ui/project/view/project_view_vm.dart diff --git a/lib/data/models/serializers.dart b/lib/data/models/serializers.dart index 50dbdef15..c8aaaeb02 100644 --- a/lib/data/models/serializers.dart +++ b/lib/data/models/serializers.dart @@ -22,6 +22,9 @@ import 'package:invoiceninja_flutter/redux/client/client_state.dart'; import 'package:invoiceninja_flutter/redux/ui/ui_state.dart'; import 'package:invoiceninja_flutter/redux/invoice/invoice_state.dart'; // STARTER: import - do not remove comment +import 'package:invoiceninja_flutter/data/models/project_model.dart'; +import 'package:invoiceninja_flutter/redux/project/project_state.dart'; + import 'package:invoiceninja_flutter/redux/payment/payment_state.dart'; import 'package:invoiceninja_flutter/redux/quote/quote_state.dart'; @@ -75,6 +78,8 @@ part 'serializers.g.dart'; TimezoneItemResponse, TimezoneListResponse, // STARTER: serializers - do not remove comment +ProjectEntity, + PaymentEntity, diff --git a/lib/data/models/serializers.g.dart b/lib/data/models/serializers.g.dart index a5c0f616c..ca949d0a9 100644 --- a/lib/data/models/serializers.g.dart +++ b/lib/data/models/serializers.g.dart @@ -99,6 +99,8 @@ Serializers _$serializers = (new Serializers().toBuilder() ..add(ProjectEntity.serializer) ..add(ProjectItemResponse.serializer) ..add(ProjectListResponse.serializer) + ..add(ProjectState.serializer) + ..add(ProjectUIState.serializer) ..add(QuoteState.serializer) ..add(QuoteUIState.serializer) ..add(SizeEntity.serializer) @@ -349,5 +351,7 @@ Serializers _$serializers = (new Serializers().toBuilder() ..addBuilderFactory(const FullType(BuiltMap, const [const FullType(int), const FullType(PaymentEntity)]), () => new MapBuilder()) ..addBuilderFactory(const FullType(BuiltList, const [const FullType(int)]), () => new ListBuilder()) ..addBuilderFactory(const FullType(BuiltMap, const [const FullType(int), const FullType(ProductEntity)]), () => new MapBuilder()) + ..addBuilderFactory(const FullType(BuiltList, const [const FullType(int)]), () => new ListBuilder()) + ..addBuilderFactory(const FullType(BuiltMap, const [const FullType(int), const FullType(ProjectEntity)]), () => new MapBuilder()) ..addBuilderFactory(const FullType(BuiltList, const [const FullType(int)]), () => new ListBuilder())) .build(); diff --git a/lib/data/repositories/project_repository.dart b/lib/data/repositories/project_repository.dart new file mode 100644 index 000000000..2dbfda83c --- /dev/null +++ b/lib/data/repositories/project_repository.dart @@ -0,0 +1,69 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:core'; +import 'package:built_collection/built_collection.dart'; +import 'package:invoiceninja_flutter/constants.dart'; +import 'package:invoiceninja_flutter/data/models/serializers.dart'; +import 'package:invoiceninja_flutter/redux/auth/auth_state.dart'; +import 'package:invoiceninja_flutter/data/models/models.dart'; +import 'package:invoiceninja_flutter/data/web_client.dart'; + +class ProjectRepository { + final WebClient webClient; + + const ProjectRepository({ + this.webClient = const WebClient(), + }); + + Future loadItem( + CompanyEntity company, AuthState auth, int entityId) async { + final dynamic response = await webClient.get( + '${auth.url}/projects/$entityId', company.token); + + final ProjectItemResponse projectResponse = + serializers.deserializeWith(ProjectItemResponse.serializer, response); + + return projectResponse.data; + } + + Future> loadList( + CompanyEntity company, AuthState auth, int updatedAt) async { + String url = auth.url + '/projects?'; + + if (updatedAt > 0) { + url += '&updated_at=${updatedAt - kUpdatedAtBufferSeconds}'; + } + + final dynamic response = await webClient.get(url, company.token); + + final ProjectListResponse projectResponse = + serializers.deserializeWith(ProjectListResponse.serializer, response); + + return projectResponse.data; + } + + Future saveData( + CompanyEntity company, AuthState auth, ProjectEntity project, + [EntityAction action]) async { + final data = serializers.serializeWith(ProjectEntity.serializer, project); + dynamic response; + + if (project.isNew) { + response = await webClient.post( + auth.url + '/projects', + company.token, + json.encode(data)); + } else { + var url = auth.url + '/projects/' + project.id.toString(); + if (action != null) { + url += '?action=' + action.toString(); + } + response = await webClient.put(url, company.token, json.encode(data)); + } + + final ProjectItemResponse projectResponse = + serializers.deserializeWith(ProjectItemResponse.serializer, response); + + return projectResponse.data; + } +} diff --git a/lib/main.dart b/lib/main.dart index f9a394f55..a9055cc11 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -40,6 +40,12 @@ import 'package:local_auth/local_auth.dart'; //import 'package:quick_actions/quick_actions.dart'; // STARTER: import - do not remove comment +import 'package:invoiceninja_flutter/ui/project/project_screen.dart'; +import 'package:invoiceninja_flutter/ui/project/edit/project_edit_vm.dart'; +import 'package:invoiceninja_flutter/ui/project/view/project_view_vm.dart'; +import 'package:invoiceninja_flutter/redux/project/project_actions.dart'; +import 'package:invoiceninja_flutter/redux/project/project_middleware.dart'; + import 'package:invoiceninja_flutter/ui/payment/payment_screen.dart'; import 'package:invoiceninja_flutter/ui/payment/edit/payment_edit_vm.dart'; import 'package:invoiceninja_flutter/ui/payment/view/payment_view_vm.dart'; @@ -69,6 +75,8 @@ void main() async { ..addAll(createStoreInvoicesMiddleware()) ..addAll(createStorePersistenceMiddleware()) // STARTER: middleware - do not remove comment +..addAll(createStoreProjectsMiddleware()) + ..addAll(createStorePaymentsMiddleware()) ..addAll(createStoreQuotesMiddleware()) ..addAll([ @@ -240,6 +248,13 @@ class InvoiceNinjaAppState extends State { InvoiceEditScreen.route: (context) => InvoiceEditScreen(), InvoiceEmailScreen.route: (context) => InvoiceEmailScreen(), // STARTER: routes - do not remove comment +ProjectScreen.route: (context) { +widget.store.dispatch(LoadProjects()); +return ProjectScreen(); +}, +ProjectViewScreen.route: (context) => ProjectViewScreen(), +ProjectEditScreen.route: (context) => ProjectEditScreen(), + PaymentScreen.route: (context) { if (widget.store.state.paymentState.isStale) { widget.store.dispatch(LoadPayments()); diff --git a/lib/redux/app/app_state.dart b/lib/redux/app/app_state.dart index bc05fd70f..5f8680917 100644 --- a/lib/redux/app/app_state.dart +++ b/lib/redux/app/app_state.dart @@ -13,6 +13,8 @@ import 'package:built_value/built_value.dart'; import 'package:built_value/serializer.dart'; // STARTER: import - do not remove comment +import 'package:invoiceninja_flutter/redux/project/project_state.dart'; + import 'package:invoiceninja_flutter/redux/payment/payment_state.dart'; import 'package:invoiceninja_flutter/redux/quote/quote_state.dart'; @@ -103,9 +105,10 @@ abstract class AppState implements Built { case EntityType.invoice: return invoiceUIState; // STARTER: states switch - do not remove comment + case EntityType.project: + return projectUIState; case EntityType.payment: return paymentUIState; - case EntityType.quote: return quoteUIState; @@ -137,6 +140,12 @@ abstract class AppState implements Built { ListUIState get invoiceListState => uiState.invoiceUIState.listUIState; // STARTER: state getters - do not remove comment + ProjectState get projectState => selectedCompanyState.projectState; + + ListUIState get projectListState => uiState.projectUIState.listUIState; + + ProjectUIState get projectUIState => uiState.projectUIState; + PaymentState get paymentState => selectedCompanyState.paymentState; ListUIState get paymentListState => uiState.paymentUIState.listUIState; diff --git a/lib/redux/company/company_reducer.dart b/lib/redux/company/company_reducer.dart index acf4466e9..3c1339676 100644 --- a/lib/redux/company/company_reducer.dart +++ b/lib/redux/company/company_reducer.dart @@ -9,6 +9,8 @@ import 'package:invoiceninja_flutter/redux/dashboard/dashboard_reducer.dart'; import 'package:invoiceninja_flutter/redux/company/company_actions.dart'; // STARTER: import - do not remove comment +import 'package:invoiceninja_flutter/redux/project/project_reducer.dart'; + import 'package:invoiceninja_flutter/redux/payment/payment_reducer.dart'; import 'package:invoiceninja_flutter/redux/quote/quote_reducer.dart'; @@ -25,6 +27,8 @@ CompanyState companyReducer(CompanyState state, dynamic action) { ..productState.replace(productsReducer(state.productState, action)) ..invoiceState.replace(invoicesReducer(state.invoiceState, action)) // STARTER: reducer - do not remove comment +..projectState.replace(projectsReducer(state.projectState, action)) + ..paymentState.replace(paymentsReducer(state.paymentState, action)) ..quoteState.replace(quotesReducer(state.quoteState, action))); diff --git a/lib/redux/company/company_state.dart b/lib/redux/company/company_state.dart index c644709a2..d79751af2 100644 --- a/lib/redux/company/company_state.dart +++ b/lib/redux/company/company_state.dart @@ -7,6 +7,8 @@ import 'package:built_value/built_value.dart'; import 'package:built_value/serializer.dart'; // STARTER: import - do not remove comment +import 'package:invoiceninja_flutter/redux/project/project_state.dart'; + import 'package:invoiceninja_flutter/redux/payment/payment_state.dart'; import 'package:invoiceninja_flutter/redux/quote/quote_state.dart'; @@ -23,6 +25,8 @@ abstract class CompanyState clientState: ClientState(), invoiceState: InvoiceState(), // STARTER: constructor - do not remove comment +projectState: ProjectState(), + paymentState: PaymentState(), quoteState: QuoteState(), ); @@ -42,6 +46,8 @@ abstract class CompanyState InvoiceState get invoiceState; // STARTER: fields - do not remove comment +ProjectState get projectState; + PaymentState get paymentState; QuoteState get quoteState; diff --git a/lib/redux/company/company_state.g.dart b/lib/redux/company/company_state.g.dart index 06309e048..f493015cc 100644 --- a/lib/redux/company/company_state.g.dart +++ b/lib/redux/company/company_state.g.dart @@ -44,6 +44,9 @@ class _$CompanyStateSerializer implements StructuredSerializer { 'invoiceState', serializers.serialize(object.invoiceState, specifiedType: const FullType(InvoiceState)), + 'projectState', + serializers.serialize(object.projectState, + specifiedType: const FullType(ProjectState)), 'paymentState', serializers.serialize(object.paymentState, specifiedType: const FullType(PaymentState)), @@ -92,6 +95,10 @@ class _$CompanyStateSerializer implements StructuredSerializer { result.invoiceState.replace(serializers.deserialize(value, specifiedType: const FullType(InvoiceState)) as InvoiceState); break; + case 'projectState': + result.projectState.replace(serializers.deserialize(value, + specifiedType: const FullType(ProjectState)) as ProjectState); + break; case 'paymentState': result.paymentState.replace(serializers.deserialize(value, specifiedType: const FullType(PaymentState)) as PaymentState); @@ -119,6 +126,8 @@ class _$CompanyState extends CompanyState { @override final InvoiceState invoiceState; @override + final ProjectState projectState; + @override final PaymentState paymentState; @override final QuoteState quoteState; @@ -132,6 +141,7 @@ class _$CompanyState extends CompanyState { this.productState, this.clientState, this.invoiceState, + this.projectState, this.paymentState, this.quoteState}) : super._() { @@ -147,6 +157,9 @@ class _$CompanyState extends CompanyState { if (invoiceState == null) { throw new BuiltValueNullFieldError('CompanyState', 'invoiceState'); } + if (projectState == null) { + throw new BuiltValueNullFieldError('CompanyState', 'projectState'); + } if (paymentState == null) { throw new BuiltValueNullFieldError('CompanyState', 'paymentState'); } @@ -171,6 +184,7 @@ class _$CompanyState extends CompanyState { productState == other.productState && clientState == other.clientState && invoiceState == other.invoiceState && + projectState == other.projectState && paymentState == other.paymentState && quoteState == other.quoteState; } @@ -181,10 +195,14 @@ class _$CompanyState extends CompanyState { $jc( $jc( $jc( - $jc($jc($jc(0, company.hashCode), dashboardState.hashCode), - productState.hashCode), - clientState.hashCode), - invoiceState.hashCode), + $jc( + $jc( + $jc($jc(0, company.hashCode), + dashboardState.hashCode), + productState.hashCode), + clientState.hashCode), + invoiceState.hashCode), + projectState.hashCode), paymentState.hashCode), quoteState.hashCode)); } @@ -197,6 +215,7 @@ class _$CompanyState extends CompanyState { ..add('productState', productState) ..add('clientState', clientState) ..add('invoiceState', invoiceState) + ..add('projectState', projectState) ..add('paymentState', paymentState) ..add('quoteState', quoteState)) .toString(); @@ -236,6 +255,12 @@ class CompanyStateBuilder set invoiceState(InvoiceStateBuilder invoiceState) => _$this._invoiceState = invoiceState; + ProjectStateBuilder _projectState; + ProjectStateBuilder get projectState => + _$this._projectState ??= new ProjectStateBuilder(); + set projectState(ProjectStateBuilder projectState) => + _$this._projectState = projectState; + PaymentStateBuilder _paymentState; PaymentStateBuilder get paymentState => _$this._paymentState ??= new PaymentStateBuilder(); @@ -257,6 +282,7 @@ class CompanyStateBuilder _productState = _$v.productState?.toBuilder(); _clientState = _$v.clientState?.toBuilder(); _invoiceState = _$v.invoiceState?.toBuilder(); + _projectState = _$v.projectState?.toBuilder(); _paymentState = _$v.paymentState?.toBuilder(); _quoteState = _$v.quoteState?.toBuilder(); _$v = null; @@ -288,6 +314,7 @@ class CompanyStateBuilder productState: productState.build(), clientState: clientState.build(), invoiceState: invoiceState.build(), + projectState: projectState.build(), paymentState: paymentState.build(), quoteState: quoteState.build()); } catch (_) { @@ -303,6 +330,8 @@ class CompanyStateBuilder clientState.build(); _$failedField = 'invoiceState'; invoiceState.build(); + _$failedField = 'projectState'; + projectState.build(); _$failedField = 'paymentState'; paymentState.build(); _$failedField = 'quoteState'; diff --git a/lib/redux/project/project_actions.dart b/lib/redux/project/project_actions.dart new file mode 100644 index 000000000..25cddd33f --- /dev/null +++ b/lib/redux/project/project_actions.dart @@ -0,0 +1,226 @@ +import 'dart:async'; +import 'package:flutter/widgets.dart'; +import 'package:built_collection/built_collection.dart'; +import 'package:invoiceninja_flutter/data/models/models.dart'; +import 'package:invoiceninja_flutter/redux/app/app_actions.dart'; + +class ViewProjectList implements PersistUI { + ViewProjectList(this.context); + + final BuildContext context; +} + +class ViewProject implements PersistUI { + ViewProject({this.projectId, this.context}); + + final int projectId; + final BuildContext context; +} + +class EditProject implements PersistUI { + EditProject({this.project, this.context, this.completer, this.trackRoute = true}); + + final ProjectEntity project; + final BuildContext context; + final Completer completer; + final bool trackRoute; +} + +class UpdateProject implements PersistUI { + UpdateProject(this.project); + + final ProjectEntity project; +} + +class LoadProject { + LoadProject({this.completer, this.projectId, this.loadActivities = false}); + + final Completer completer; + final int projectId; + final bool loadActivities; +} + +class LoadProjectActivity { + LoadProjectActivity({this.completer, this.projectId}); + + final Completer completer; + final int projectId; +} + +class LoadProjects { + LoadProjects({this.completer, this.force = false}); + + final Completer completer; + final bool force; +} + +class LoadProjectRequest implements StartLoading {} + +class LoadProjectFailure implements StopLoading { + LoadProjectFailure(this.error); + + final dynamic error; + + @override + String toString() { + return 'LoadProjectFailure{error: $error}'; + } +} + +class LoadProjectSuccess implements StopLoading, PersistData { + LoadProjectSuccess(this.project); + + final ProjectEntity project; + + @override + String toString() { + return 'LoadProjectSuccess{project: $project}'; + } +} + +class LoadProjectsRequest implements StartLoading {} + +class LoadProjectsFailure implements StopLoading { + LoadProjectsFailure(this.error); + + final dynamic error; + + @override + String toString() { + return 'LoadProjectsFailure{error: $error}'; + } +} + +class LoadProjectsSuccess implements StopLoading, PersistData { + LoadProjectsSuccess(this.projects); + + final BuiltList projects; + + @override + String toString() { + return 'LoadProjectsSuccess{projects: $projects}'; + } +} + + +class SaveProjectRequest implements StartSaving { + SaveProjectRequest({this.completer, this.project}); + + final Completer completer; + final ProjectEntity project; +} + +class SaveProjectSuccess implements StopSaving, PersistData, PersistUI { + SaveProjectSuccess(this.project); + + final ProjectEntity project; +} + +class AddProjectSuccess implements StopSaving, PersistData, PersistUI { + AddProjectSuccess(this.project); + + final ProjectEntity project; +} + +class SaveProjectFailure implements StopSaving { + SaveProjectFailure (this.error); + + final Object error; +} + +class ArchiveProjectRequest implements StartSaving { + ArchiveProjectRequest(this.completer, this.projectId); + + final Completer completer; + final int projectId; +} + +class ArchiveProjectSuccess implements StopSaving, PersistData { + ArchiveProjectSuccess(this.project); + + final ProjectEntity project; +} + +class ArchiveProjectFailure implements StopSaving { + ArchiveProjectFailure(this.project); + + final ProjectEntity project; +} + +class DeleteProjectRequest implements StartSaving { + DeleteProjectRequest(this.completer, this.projectId); + + final Completer completer; + final int projectId; +} + +class DeleteProjectSuccess implements StopSaving, PersistData { + DeleteProjectSuccess(this.project); + + final ProjectEntity project; +} + +class DeleteProjectFailure implements StopSaving { + DeleteProjectFailure(this.project); + + final ProjectEntity project; +} + +class RestoreProjectRequest implements StartSaving { + RestoreProjectRequest(this.completer, this.projectId); + + final Completer completer; + final int projectId; +} + +class RestoreProjectSuccess implements StopSaving, PersistData { + RestoreProjectSuccess(this.project); + + final ProjectEntity project; +} + +class RestoreProjectFailure implements StopSaving { + RestoreProjectFailure(this.project); + + final ProjectEntity project; +} + + + + +class FilterProjects { + FilterProjects(this.filter); + + final String filter; +} + +class SortProjects implements PersistUI { + SortProjects(this.field); + + final String field; +} + +class FilterProjectsByState implements PersistUI { + FilterProjectsByState(this.state); + + final EntityState state; +} + +class FilterProjectsByCustom1 implements PersistUI { + FilterProjectsByCustom1(this.value); + + final String value; +} + +class FilterProjectsByCustom2 implements PersistUI { + FilterProjectsByCustom2(this.value); + + final String value; +} + +class FilterProjectsByEntity implements PersistUI { + FilterProjectsByEntity({this.entityId, this.entityType}); + + final int entityId; + final EntityType entityType; +} diff --git a/lib/redux/project/project_middleware.dart b/lib/redux/project/project_middleware.dart new file mode 100644 index 000000000..b63a084ba --- /dev/null +++ b/lib/redux/project/project_middleware.dart @@ -0,0 +1,231 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:redux/redux.dart'; +import 'package:invoiceninja_flutter/data/models/models.dart'; +import 'package:invoiceninja_flutter/redux/product/product_actions.dart'; +import 'package:invoiceninja_flutter/redux/ui/ui_actions.dart'; +import 'package:invoiceninja_flutter/ui/project/project_screen.dart'; +import 'package:invoiceninja_flutter/ui/project/edit/project_edit_vm.dart'; +import 'package:invoiceninja_flutter/ui/project/view/project_view_vm.dart'; +import 'package:invoiceninja_flutter/redux/project/project_actions.dart'; +import 'package:invoiceninja_flutter/redux/app/app_state.dart'; +import 'package:invoiceninja_flutter/data/repositories/project_repository.dart'; + +List> createStoreProjectsMiddleware([ + ProjectRepository repository = const ProjectRepository(), +]) { + final viewProjectList = _viewProjectList(); + final viewProject = _viewProject(); + final editProject = _editProject(); + final loadProjects = _loadProjects(repository); + final loadProject = _loadProject(repository); + final saveProject = _saveProject(repository); + final archiveProject = _archiveProject(repository); + final deleteProject = _deleteProject(repository); + final restoreProject = _restoreProject(repository); + + return [ + TypedMiddleware(viewProjectList), + TypedMiddleware(viewProject), + TypedMiddleware(editProject), + TypedMiddleware(loadProjects), + TypedMiddleware(loadProject), + TypedMiddleware(saveProject), + TypedMiddleware(archiveProject), + TypedMiddleware(deleteProject), + TypedMiddleware(restoreProject), + ]; +} + +Middleware _editProject() { + return (Store store, dynamic action, NextDispatcher next) async { + next(action); + + final project = + await Navigator.of(action.context).pushNamed(ProjectEditScreen.route); + + if (action.completer != null && project != null) { + action.completer.complete(project); + } + }; +} + +Middleware _viewProject() { + return (Store store, dynamic action, NextDispatcher next) async { + next(action); + + store.dispatch(UpdateCurrentRoute(ProjectViewScreen.route)); + Navigator.of(action.context).pushNamed(ProjectViewScreen.route); + }; +} + +Middleware _viewProjectList() { + return (Store store, dynamic action, NextDispatcher next) { + next(action); + + store.dispatch(UpdateCurrentRoute(ProjectScreen.route)); + + Navigator.of(action.context).pushNamedAndRemoveUntil(ProjectScreen.route, (Route route) => false); + }; +} + +Middleware _archiveProject(ProjectRepository repository) { + return (Store store, dynamic action, NextDispatcher next) { + final origProject = store.state.projectState.map[action.projectId]; + repository + .saveData(store.state.selectedCompany, store.state.authState, + origProject, EntityAction.archive) + .then((ProjectEntity project) { + store.dispatch(ArchiveProjectSuccess(project)); + if (action.completer != null) { + action.completer.complete(null); + } + }).catchError((Object error) { + print(error); + store.dispatch(ArchiveProjectFailure(origProject)); + if (action.completer != null) { + action.completer.completeError(error); + } + }); + + next(action); + }; +} + +Middleware _deleteProject(ProjectRepository repository) { + return (Store store, dynamic action, NextDispatcher next) { + final origProject = store.state.projectState.map[action.projectId]; + repository + .saveData(store.state.selectedCompany, store.state.authState, + origProject, EntityAction.delete) + .then((ProjectEntity project) { + store.dispatch(DeleteProjectSuccess(project)); + if (action.completer != null) { + action.completer.complete(null); + } + }).catchError((Object error) { + print(error); + store.dispatch(DeleteProjectFailure(origProject)); + if (action.completer != null) { + action.completer.completeError(error); + } + }); + + next(action); + }; +} + +Middleware _restoreProject(ProjectRepository repository) { + return (Store store, dynamic action, NextDispatcher next) { + final origProject = store.state.projectState.map[action.projectId]; + repository + .saveData(store.state.selectedCompany, store.state.authState, + origProject, EntityAction.restore) + .then((ProjectEntity project) { + store.dispatch(RestoreProjectSuccess(project)); + if (action.completer != null) { + action.completer.complete(null); + } + }).catchError((Object error) { + print(error); + store.dispatch(RestoreProjectFailure(origProject)); + if (action.completer != null) { + action.completer.completeError(error); + } + }); + + next(action); + }; +} + +Middleware _saveProject(ProjectRepository repository) { + return (Store store, dynamic action, NextDispatcher next) { + repository + .saveData( + store.state.selectedCompany, store.state.authState, action.project) + .then((ProjectEntity project) { + if (action.project.isNew) { + store.dispatch(AddProjectSuccess(project)); + } else { + store.dispatch(SaveProjectSuccess(project)); + } + action.completer.complete(project); + }).catchError((Object error) { + print(error); + store.dispatch(SaveProjectFailure(error)); + action.completer.completeError(error); + }); + + next(action); + }; +} + +Middleware _loadProject(ProjectRepository repository) { + return (Store store, dynamic action, NextDispatcher next) { + final AppState state = store.state; + + if (state.isLoading) { + next(action); + return; + } + + store.dispatch(LoadProjectRequest()); + repository + .loadItem(state.selectedCompany, state.authState, action.projectId) + .then((project) { + store.dispatch(LoadProjectSuccess(project)); + + if (action.completer != null) { + action.completer.complete(null); + } + }).catchError((Object error) { + print(error); + store.dispatch(LoadProjectFailure(error)); + if (action.completer != null) { + action.completer.completeError(error); + } + }); + + next(action); + }; +} + +Middleware _loadProjects(ProjectRepository repository) { + return (Store store, dynamic action, NextDispatcher next) { + final AppState state = store.state; + + if (!state.projectState.isStale && !action.force) { + next(action); + return; + } + + if (state.isLoading) { + next(action); + return; + } + + final int updatedAt = (state.projectState.lastUpdated / 1000).round(); + + store.dispatch(LoadProjectsRequest()); + repository + .loadList(state.selectedCompany, state.authState, updatedAt) + .then((data) { + store.dispatch(LoadProjectsSuccess(data)); + + if (action.completer != null) { + action.completer.complete(null); + } + if (state.productState.isStale) { + store.dispatch(LoadProducts()); + } + }).catchError((Object error) { + print(error); + store.dispatch(LoadProjectsFailure(error)); + if (action.completer != null) { + action.completer.completeError(error); + } + }); + + next(action); + }; +} diff --git a/lib/redux/project/project_reducer.dart b/lib/redux/project/project_reducer.dart new file mode 100644 index 000000000..1bb7fff06 --- /dev/null +++ b/lib/redux/project/project_reducer.dart @@ -0,0 +1,197 @@ +import 'package:redux/redux.dart'; +import 'package:invoiceninja_flutter/data/models/models.dart'; +import 'package:invoiceninja_flutter/redux/company/company_actions.dart'; +import 'package:invoiceninja_flutter/redux/ui/entity_ui_state.dart'; +import 'package:invoiceninja_flutter/redux/ui/list_ui_state.dart'; +import 'package:invoiceninja_flutter/redux/project/project_actions.dart'; +import 'package:invoiceninja_flutter/redux/project/project_state.dart'; + +EntityUIState projectUIReducer(ProjectUIState state, dynamic action) { + return state.rebuild((b) => b + ..listUIState.replace(projectListReducer(state.listUIState, action)) + ..editing.replace(editingReducer(state.editing, action)) + ..selectedId = selectedIdReducer(state.selectedId, action)); +} + +Reducer selectedIdReducer = combineReducers([ + TypedReducer( + (int selectedId, dynamic action) => action.projectId), + TypedReducer( + (int selectedId, dynamic action) => action.project.id), +]); + +final editingReducer = combineReducers([ + TypedReducer(_updateEditing), + TypedReducer(_updateEditing), + TypedReducer(_updateEditing), + TypedReducer(_updateEditing), + TypedReducer(_updateEditing), + TypedReducer(_updateEditing), + TypedReducer(_updateEditing), + TypedReducer(_clearEditing), +]); + +ProjectEntity _clearEditing(ProjectEntity project, dynamic action) { + return ProjectEntity(); +} + +ProjectEntity _updateEditing(ProjectEntity project, dynamic action) { + return action.project; +} + + +final projectListReducer = combineReducers([ + TypedReducer(_sortProjects), + TypedReducer(_filterProjectsByState), + TypedReducer(_filterProjects), + TypedReducer(_filterProjectsByCustom1), + TypedReducer(_filterProjectsByCustom2), + TypedReducer(_filterProjectsByClient), +]); + +ListUIState _filterProjectsByClient( + ListUIState projectListState, FilterProjectsByEntity action) { + return projectListState.rebuild((b) => b + ..filterEntityId = action.entityId + ..filterEntityType = action.entityType); +} + +ListUIState _filterProjectsByCustom1( + ListUIState projectListState, FilterProjectsByCustom1 action) { + if (projectListState.custom1Filters.contains(action.value)) { + return projectListState + .rebuild((b) => b..custom1Filters.remove(action.value)); + } else { + return projectListState.rebuild((b) => b..custom1Filters.add(action.value)); + } +} + +ListUIState _filterProjectsByCustom2( + ListUIState projectListState, FilterProjectsByCustom2 action) { + if (projectListState.custom2Filters.contains(action.value)) { + return projectListState + .rebuild((b) => b..custom2Filters.remove(action.value)); + } else { + return projectListState.rebuild((b) => b..custom2Filters.add(action.value)); + } +} + +ListUIState _filterProjectsByState( + ListUIState projectListState, FilterProjectsByState action) { + if (projectListState.stateFilters.contains(action.state)) { + return projectListState.rebuild((b) => b..stateFilters.remove(action.state)); + } else { + return projectListState.rebuild((b) => b..stateFilters.add(action.state)); + } +} + +ListUIState _filterProjects(ListUIState projectListState, FilterProjects action) { + return projectListState.rebuild((b) => b..filter = action.filter); +} + +ListUIState _sortProjects(ListUIState projectListState, SortProjects action) { + return projectListState.rebuild((b) => b + ..sortAscending = b.sortField != action.field || !b.sortAscending + ..sortField = action.field); +} + +final projectsReducer = combineReducers([ + TypedReducer(_updateProject), + TypedReducer(_addProject), + TypedReducer(_setLoadedProjects), + TypedReducer(_setLoadedProject), + TypedReducer(_archiveProjectRequest), + TypedReducer(_archiveProjectSuccess), + TypedReducer(_archiveProjectFailure), + TypedReducer(_deleteProjectRequest), + TypedReducer(_deleteProjectSuccess), + TypedReducer(_deleteProjectFailure), + TypedReducer(_restoreProjectRequest), + TypedReducer(_restoreProjectSuccess), + TypedReducer(_restoreProjectFailure), +]); + +ProjectState _archiveProjectRequest( + ProjectState projectState, ArchiveProjectRequest action) { + final project = projectState.map[action.projectId] + .rebuild((b) => b..archivedAt = DateTime.now().millisecondsSinceEpoch); + + return projectState.rebuild((b) => b..map[action.projectId] = project); +} + +ProjectState _archiveProjectSuccess( + ProjectState projectState, ArchiveProjectSuccess action) { + return projectState.rebuild((b) => b..map[action.project.id] = action.project); +} + +ProjectState _archiveProjectFailure( + ProjectState projectState, ArchiveProjectFailure action) { + return projectState.rebuild((b) => b..map[action.project.id] = action.project); +} + +ProjectState _deleteProjectRequest( + ProjectState projectState, DeleteProjectRequest action) { + final project = projectState.map[action.projectId].rebuild((b) => b + ..archivedAt = DateTime.now().millisecondsSinceEpoch + ..isDeleted = true); + + return projectState.rebuild((b) => b..map[action.projectId] = project); +} + +ProjectState _deleteProjectSuccess( + ProjectState projectState, DeleteProjectSuccess action) { + return projectState.rebuild((b) => b..map[action.project.id] = action.project); +} + +ProjectState _deleteProjectFailure( + ProjectState projectState, DeleteProjectFailure action) { + return projectState.rebuild((b) => b..map[action.project.id] = action.project); +} + +ProjectState _restoreProjectRequest( + ProjectState projectState, RestoreProjectRequest action) { + final project = projectState.map[action.projectId].rebuild((b) => b + ..archivedAt = null + ..isDeleted = false); + return projectState.rebuild((b) => b..map[action.projectId] = project); +} + +ProjectState _restoreProjectSuccess( + ProjectState projectState, RestoreProjectSuccess action) { + return projectState.rebuild((b) => b..map[action.project.id] = action.project); +} + +ProjectState _restoreProjectFailure( + ProjectState projectState, RestoreProjectFailure action) { + return projectState.rebuild((b) => b..map[action.project.id] = action.project); +} + +ProjectState _addProject(ProjectState projectState, AddProjectSuccess action) { + return projectState.rebuild((b) => b + ..map[action.project.id] = action.project + ..list.add(action.project.id)); +} + +ProjectState _updateProject(ProjectState projectState, SaveProjectSuccess action) { + return projectState.rebuild((b) => b + ..map[action.project.id] = action.project); +} + +ProjectState _setLoadedProject( + ProjectState projectState, LoadProjectSuccess action) { + return projectState.rebuild((b) => b + ..map[action.project.id] = action.project); +} + +ProjectState _setLoadedProjects( + ProjectState projectState, LoadProjectsSuccess action) { + final state = projectState.rebuild((b) => b + ..lastUpdated = DateTime.now().millisecondsSinceEpoch + ..map.addAll(Map.fromIterable( + action.projects, + key: (dynamic item) => item.id, + value: (dynamic item) => item, + ))); + + return state.rebuild((b) => b..list.replace(state.map.keys)); +} diff --git a/lib/redux/project/project_selectors.dart b/lib/redux/project/project_selectors.dart new file mode 100644 index 000000000..fa68aee1b --- /dev/null +++ b/lib/redux/project/project_selectors.dart @@ -0,0 +1,60 @@ +import 'package:memoize/memoize.dart'; +import 'package:built_collection/built_collection.dart'; +import 'package:invoiceninja_flutter/data/models/models.dart'; +import 'package:invoiceninja_flutter/redux/ui/list_ui_state.dart'; + +var memoizedDropdownProjectList = memo2( + (BuiltMap projectMap, BuiltList projectList) => + dropdownProjectsSelector(projectMap, projectList)); + +List dropdownProjectsSelector( + BuiltMap projectMap, BuiltList projectList) { + final list = + projectList.where((projectId) => projectMap[projectId].isActive).toList(); + + list.sort((projectAId, projectBId) { + final projectA = projectMap[projectAId]; + final projectB = projectMap[projectBId]; + return projectA.compareTo(projectB, ProjectFields.name, true); + }); + + return list; +} + +var memoizedFilteredProjectList = memo3((BuiltMap projectMap, + BuiltList projectList, ListUIState projectListState) => + filteredProjectsSelector(projectMap, projectList, projectListState)); + +List filteredProjectsSelector(BuiltMap projectMap, + BuiltList projectList, ListUIState projectListState) { + final list = projectList.where((projectId) { + final project = projectMap[projectId]; + if (!project.matchesStates(projectListState.stateFilters)) { + return false; + } + if (projectListState.custom1Filters.isNotEmpty && + !projectListState.custom1Filters.contains(project.customValue1)) { + return false; + } + if (projectListState.custom2Filters.isNotEmpty && + !projectListState.custom2Filters.contains(project.customValue2)) { + return false; + } + /* + if (projectListState.filterEntityId != null && + project.entityId != projectListState.filterEntityId) { + return false; + } + */ + return project.matchesFilter(projectListState.filter); + }).toList(); + + list.sort((projectAId, projectBId) { + final projectA = projectMap[projectAId]; + final projectB = projectMap[projectBId]; + return projectA.compareTo( + projectB, projectListState.sortField, projectListState.sortAscending); + }); + + return list; +} diff --git a/lib/redux/project/project_state.dart b/lib/redux/project/project_state.dart new file mode 100644 index 000000000..2598ca561 --- /dev/null +++ b/lib/redux/project/project_state.dart @@ -0,0 +1,59 @@ +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; +import 'package:built_collection/built_collection.dart'; +import 'package:invoiceninja_flutter/constants.dart'; +import 'package:invoiceninja_flutter/data/models/project_model.dart'; +import 'package:invoiceninja_flutter/redux/ui/entity_ui_state.dart'; +import 'package:invoiceninja_flutter/redux/ui/list_ui_state.dart'; + +part 'project_state.g.dart'; + +abstract class ProjectState implements Built { + + factory ProjectState() { + return _$ProjectState._( + lastUpdated: 0, + map: BuiltMap(), + list: BuiltList(), + ); + } + ProjectState._(); + + @nullable + int get lastUpdated; + + BuiltMap get map; + BuiltList get list; + + bool get isStale { + if (! isLoaded) { + return true; + } + + return DateTime.now().millisecondsSinceEpoch - lastUpdated > kMillisecondsToRefreshData; + } + + bool get isLoaded => lastUpdated != null && lastUpdated > 0; + + static Serializer get serializer => _$projectStateSerializer; +} + +abstract class ProjectUIState extends Object with EntityUIState implements Built { + + factory ProjectUIState() { + return _$ProjectUIState._( + listUIState: ListUIState(ProjectFields.name), + editing: ProjectEntity(), + selectedId: 0, + ); + } + ProjectUIState._(); + + @nullable + ProjectEntity get editing; + + @override + bool get isCreatingNew => editing.isNew; + + static Serializer get serializer => _$projectUIStateSerializer; +} \ No newline at end of file diff --git a/lib/redux/project/project_state.g.dart b/lib/redux/project/project_state.g.dart new file mode 100644 index 000000000..9bafa8fc0 --- /dev/null +++ b/lib/redux/project/project_state.g.dart @@ -0,0 +1,392 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'project_state.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +// ignore_for_file: always_put_control_body_on_new_line +// ignore_for_file: annotate_overrides +// ignore_for_file: avoid_annotating_with_dynamic +// ignore_for_file: avoid_catches_without_on_clauses +// ignore_for_file: avoid_returning_this +// ignore_for_file: lines_longer_than_80_chars +// ignore_for_file: omit_local_variable_types +// ignore_for_file: prefer_expression_function_bodies +// ignore_for_file: sort_constructors_first +// ignore_for_file: unnecessary_const +// ignore_for_file: unnecessary_new +// ignore_for_file: test_types_in_equals + +Serializer _$projectStateSerializer = + new _$ProjectStateSerializer(); +Serializer _$projectUIStateSerializer = + new _$ProjectUIStateSerializer(); + +class _$ProjectStateSerializer implements StructuredSerializer { + @override + final Iterable types = const [ProjectState, _$ProjectState]; + @override + final String wireName = 'ProjectState'; + + @override + Iterable serialize(Serializers serializers, ProjectState object, + {FullType specifiedType = FullType.unspecified}) { + final result = [ + 'map', + serializers.serialize(object.map, + specifiedType: const FullType(BuiltMap, + const [const FullType(int), const FullType(ProjectEntity)])), + 'list', + serializers.serialize(object.list, + specifiedType: + const FullType(BuiltList, const [const FullType(int)])), + ]; + if (object.lastUpdated != null) { + result + ..add('lastUpdated') + ..add(serializers.serialize(object.lastUpdated, + specifiedType: const FullType(int))); + } + + return result; + } + + @override + ProjectState deserialize(Serializers serializers, Iterable serialized, + {FullType specifiedType = FullType.unspecified}) { + final result = new ProjectStateBuilder(); + + final iterator = serialized.iterator; + while (iterator.moveNext()) { + final key = iterator.current as String; + iterator.moveNext(); + final dynamic value = iterator.current; + switch (key) { + case 'lastUpdated': + result.lastUpdated = serializers.deserialize(value, + specifiedType: const FullType(int)) as int; + break; + case 'map': + result.map.replace(serializers.deserialize(value, + specifiedType: const FullType(BuiltMap, const [ + const FullType(int), + const FullType(ProjectEntity) + ])) as BuiltMap); + break; + case 'list': + result.list.replace(serializers.deserialize(value, + specifiedType: + const FullType(BuiltList, const [const FullType(int)])) + as BuiltList); + break; + } + } + + return result.build(); + } +} + +class _$ProjectUIStateSerializer + implements StructuredSerializer { + @override + final Iterable types = const [ProjectUIState, _$ProjectUIState]; + @override + final String wireName = 'ProjectUIState'; + + @override + Iterable serialize(Serializers serializers, ProjectUIState object, + {FullType specifiedType = FullType.unspecified}) { + final result = [ + 'selectedId', + serializers.serialize(object.selectedId, + specifiedType: const FullType(int)), + 'listUIState', + serializers.serialize(object.listUIState, + specifiedType: const FullType(ListUIState)), + ]; + if (object.editing != null) { + result + ..add('editing') + ..add(serializers.serialize(object.editing, + specifiedType: const FullType(ProjectEntity))); + } + + return result; + } + + @override + ProjectUIState deserialize(Serializers serializers, Iterable serialized, + {FullType specifiedType = FullType.unspecified}) { + final result = new ProjectUIStateBuilder(); + + final iterator = serialized.iterator; + while (iterator.moveNext()) { + final key = iterator.current as String; + iterator.moveNext(); + final dynamic value = iterator.current; + switch (key) { + case 'editing': + result.editing.replace(serializers.deserialize(value, + specifiedType: const FullType(ProjectEntity)) as ProjectEntity); + break; + case 'selectedId': + result.selectedId = serializers.deserialize(value, + specifiedType: const FullType(int)) as int; + break; + case 'listUIState': + result.listUIState.replace(serializers.deserialize(value, + specifiedType: const FullType(ListUIState)) as ListUIState); + break; + } + } + + return result.build(); + } +} + +class _$ProjectState extends ProjectState { + @override + final int lastUpdated; + @override + final BuiltMap map; + @override + final BuiltList list; + + factory _$ProjectState([void updates(ProjectStateBuilder b)]) => + (new ProjectStateBuilder()..update(updates)).build(); + + _$ProjectState._({this.lastUpdated, this.map, this.list}) : super._() { + if (map == null) { + throw new BuiltValueNullFieldError('ProjectState', 'map'); + } + if (list == null) { + throw new BuiltValueNullFieldError('ProjectState', 'list'); + } + } + + @override + ProjectState rebuild(void updates(ProjectStateBuilder b)) => + (toBuilder()..update(updates)).build(); + + @override + ProjectStateBuilder toBuilder() => new ProjectStateBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is ProjectState && + lastUpdated == other.lastUpdated && + map == other.map && + list == other.list; + } + + @override + int get hashCode { + return $jf( + $jc($jc($jc(0, lastUpdated.hashCode), map.hashCode), list.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('ProjectState') + ..add('lastUpdated', lastUpdated) + ..add('map', map) + ..add('list', list)) + .toString(); + } +} + +class ProjectStateBuilder + implements Builder { + _$ProjectState _$v; + + int _lastUpdated; + int get lastUpdated => _$this._lastUpdated; + set lastUpdated(int lastUpdated) => _$this._lastUpdated = lastUpdated; + + MapBuilder _map; + MapBuilder get map => + _$this._map ??= new MapBuilder(); + set map(MapBuilder map) => _$this._map = map; + + ListBuilder _list; + ListBuilder get list => _$this._list ??= new ListBuilder(); + set list(ListBuilder list) => _$this._list = list; + + ProjectStateBuilder(); + + ProjectStateBuilder get _$this { + if (_$v != null) { + _lastUpdated = _$v.lastUpdated; + _map = _$v.map?.toBuilder(); + _list = _$v.list?.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(ProjectState other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$ProjectState; + } + + @override + void update(void updates(ProjectStateBuilder b)) { + if (updates != null) updates(this); + } + + @override + _$ProjectState build() { + _$ProjectState _$result; + try { + _$result = _$v ?? + new _$ProjectState._( + lastUpdated: lastUpdated, map: map.build(), list: list.build()); + } catch (_) { + String _$failedField; + try { + _$failedField = 'map'; + map.build(); + _$failedField = 'list'; + list.build(); + } catch (e) { + throw new BuiltValueNestedFieldError( + 'ProjectState', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +class _$ProjectUIState extends ProjectUIState { + @override + final ProjectEntity editing; + @override + final int selectedId; + @override + final ListUIState listUIState; + + factory _$ProjectUIState([void updates(ProjectUIStateBuilder b)]) => + (new ProjectUIStateBuilder()..update(updates)).build(); + + _$ProjectUIState._({this.editing, this.selectedId, this.listUIState}) + : super._() { + if (selectedId == null) { + throw new BuiltValueNullFieldError('ProjectUIState', 'selectedId'); + } + if (listUIState == null) { + throw new BuiltValueNullFieldError('ProjectUIState', 'listUIState'); + } + } + + @override + ProjectUIState rebuild(void updates(ProjectUIStateBuilder b)) => + (toBuilder()..update(updates)).build(); + + @override + ProjectUIStateBuilder toBuilder() => + new ProjectUIStateBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is ProjectUIState && + editing == other.editing && + selectedId == other.selectedId && + listUIState == other.listUIState; + } + + @override + int get hashCode { + return $jf($jc($jc($jc(0, editing.hashCode), selectedId.hashCode), + listUIState.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('ProjectUIState') + ..add('editing', editing) + ..add('selectedId', selectedId) + ..add('listUIState', listUIState)) + .toString(); + } +} + +class ProjectUIStateBuilder + implements Builder { + _$ProjectUIState _$v; + + ProjectEntityBuilder _editing; + ProjectEntityBuilder get editing => + _$this._editing ??= new ProjectEntityBuilder(); + set editing(ProjectEntityBuilder editing) => _$this._editing = editing; + + int _selectedId; + int get selectedId => _$this._selectedId; + set selectedId(int selectedId) => _$this._selectedId = selectedId; + + ListUIStateBuilder _listUIState; + ListUIStateBuilder get listUIState => + _$this._listUIState ??= new ListUIStateBuilder(); + set listUIState(ListUIStateBuilder listUIState) => + _$this._listUIState = listUIState; + + ProjectUIStateBuilder(); + + ProjectUIStateBuilder get _$this { + if (_$v != null) { + _editing = _$v.editing?.toBuilder(); + _selectedId = _$v.selectedId; + _listUIState = _$v.listUIState?.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(ProjectUIState other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$ProjectUIState; + } + + @override + void update(void updates(ProjectUIStateBuilder b)) { + if (updates != null) updates(this); + } + + @override + _$ProjectUIState build() { + _$ProjectUIState _$result; + try { + _$result = _$v ?? + new _$ProjectUIState._( + editing: _editing?.build(), + selectedId: selectedId, + listUIState: listUIState.build()); + } catch (_) { + String _$failedField; + try { + _$failedField = 'editing'; + _editing?.build(); + + _$failedField = 'listUIState'; + listUIState.build(); + } catch (e) { + throw new BuiltValueNestedFieldError( + 'ProjectUIState', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} diff --git a/lib/redux/ui/ui_reducer.dart b/lib/redux/ui/ui_reducer.dart index 89b34ff85..fb1ff2e13 100644 --- a/lib/redux/ui/ui_reducer.dart +++ b/lib/redux/ui/ui_reducer.dart @@ -9,6 +9,8 @@ import 'package:invoiceninja_flutter/redux/invoice/invoice_reducer.dart'; import 'package:redux/redux.dart'; // STARTER: import - do not remove comment +import 'package:invoiceninja_flutter/redux/project/project_reducer.dart'; + import 'package:invoiceninja_flutter/redux/payment/payment_reducer.dart'; import 'package:invoiceninja_flutter/redux/quote/quote_reducer.dart'; @@ -28,6 +30,8 @@ UIState uiReducer(UIState state, dynamic action) { ..invoiceUIState.replace(invoiceUIReducer(state.invoiceUIState, action)) ..dashboardUIState.replace(dashboardUIReducer(state.dashboardUIState, action)) // STARTER: reducer - do not remove comment +..projectUIState.replace(projectUIReducer(state.projectUIState, action)) + ..paymentUIState.replace(paymentUIReducer(state.paymentUIState, action)) ..quoteUIState.replace(quoteUIReducer(state.quoteUIState, action))); } diff --git a/lib/redux/ui/ui_state.dart b/lib/redux/ui/ui_state.dart index 8173c094f..96af6577a 100644 --- a/lib/redux/ui/ui_state.dart +++ b/lib/redux/ui/ui_state.dart @@ -8,6 +8,8 @@ import 'package:invoiceninja_flutter/redux/product/product_state.dart'; import 'package:invoiceninja_flutter/ui/auth/login_vm.dart'; // STARTER: import - do not remove comment +import 'package:invoiceninja_flutter/redux/project/project_state.dart'; + import 'package:invoiceninja_flutter/redux/payment/payment_state.dart'; import 'package:invoiceninja_flutter/redux/quote/quote_state.dart'; @@ -28,6 +30,8 @@ abstract class UIState implements Built { clientUIState: ClientUIState(), invoiceUIState: InvoiceUIState(), // STARTER: constructor - do not remove comment +projectUIState: ProjectUIState(), + paymentUIState: PaymentUIState(company), quoteUIState: QuoteUIState(), ); @@ -57,6 +61,8 @@ abstract class UIState implements Built { String get filter; // STARTER: properties - do not remove comment +ProjectUIState get projectUIState; + PaymentUIState get paymentUIState; QuoteUIState get quoteUIState; diff --git a/lib/redux/ui/ui_state.g.dart b/lib/redux/ui/ui_state.g.dart index 02a58137a..481333792 100644 --- a/lib/redux/ui/ui_state.g.dart +++ b/lib/redux/ui/ui_state.g.dart @@ -58,6 +58,9 @@ class _$UIStateSerializer implements StructuredSerializer { 'invoiceUIState', serializers.serialize(object.invoiceUIState, specifiedType: const FullType(InvoiceUIState)), + 'projectUIState', + serializers.serialize(object.projectUIState, + specifiedType: const FullType(ProjectUIState)), 'paymentUIState', serializers.serialize(object.paymentUIState, specifiedType: const FullType(PaymentUIState)), @@ -127,6 +130,10 @@ class _$UIStateSerializer implements StructuredSerializer { result.filter = serializers.deserialize(value, specifiedType: const FullType(String)) as String; break; + case 'projectUIState': + result.projectUIState.replace(serializers.deserialize(value, + specifiedType: const FullType(ProjectUIState)) as ProjectUIState); + break; case 'paymentUIState': result.paymentUIState.replace(serializers.deserialize(value, specifiedType: const FullType(PaymentUIState)) as PaymentUIState); @@ -164,6 +171,8 @@ class _$UIState extends UIState { @override final String filter; @override + final ProjectUIState projectUIState; + @override final PaymentUIState paymentUIState; @override final QuoteUIState quoteUIState; @@ -182,6 +191,7 @@ class _$UIState extends UIState { this.clientUIState, this.invoiceUIState, this.filter, + this.projectUIState, this.paymentUIState, this.quoteUIState}) : super._() { @@ -212,6 +222,9 @@ class _$UIState extends UIState { if (invoiceUIState == null) { throw new BuiltValueNullFieldError('UIState', 'invoiceUIState'); } + if (projectUIState == null) { + throw new BuiltValueNullFieldError('UIState', 'projectUIState'); + } if (paymentUIState == null) { throw new BuiltValueNullFieldError('UIState', 'paymentUIState'); } @@ -241,6 +254,7 @@ class _$UIState extends UIState { clientUIState == other.clientUIState && invoiceUIState == other.invoiceUIState && filter == other.filter && + projectUIState == other.projectUIState && paymentUIState == other.paymentUIState && quoteUIState == other.quoteUIState; } @@ -259,18 +273,20 @@ class _$UIState extends UIState { $jc( $jc( $jc( - 0, - selectedCompanyIndex - .hashCode), - currentRoute.hashCode), - enableDarkMode.hashCode), - requireAuthentication.hashCode), - emailPayment.hashCode), - dashboardUIState.hashCode), - productUIState.hashCode), - clientUIState.hashCode), - invoiceUIState.hashCode), - filter.hashCode), + $jc( + 0, + selectedCompanyIndex + .hashCode), + currentRoute.hashCode), + enableDarkMode.hashCode), + requireAuthentication.hashCode), + emailPayment.hashCode), + dashboardUIState.hashCode), + productUIState.hashCode), + clientUIState.hashCode), + invoiceUIState.hashCode), + filter.hashCode), + projectUIState.hashCode), paymentUIState.hashCode), quoteUIState.hashCode)); } @@ -288,6 +304,7 @@ class _$UIState extends UIState { ..add('clientUIState', clientUIState) ..add('invoiceUIState', invoiceUIState) ..add('filter', filter) + ..add('projectUIState', projectUIState) ..add('paymentUIState', paymentUIState) ..add('quoteUIState', quoteUIState)) .toString(); @@ -348,6 +365,12 @@ class UIStateBuilder implements Builder { String get filter => _$this._filter; set filter(String filter) => _$this._filter = filter; + ProjectUIStateBuilder _projectUIState; + ProjectUIStateBuilder get projectUIState => + _$this._projectUIState ??= new ProjectUIStateBuilder(); + set projectUIState(ProjectUIStateBuilder projectUIState) => + _$this._projectUIState = projectUIState; + PaymentUIStateBuilder _paymentUIState; PaymentUIStateBuilder get paymentUIState => _$this._paymentUIState ??= new PaymentUIStateBuilder(); @@ -374,6 +397,7 @@ class UIStateBuilder implements Builder { _clientUIState = _$v.clientUIState?.toBuilder(); _invoiceUIState = _$v.invoiceUIState?.toBuilder(); _filter = _$v.filter; + _projectUIState = _$v.projectUIState?.toBuilder(); _paymentUIState = _$v.paymentUIState?.toBuilder(); _quoteUIState = _$v.quoteUIState?.toBuilder(); _$v = null; @@ -410,6 +434,7 @@ class UIStateBuilder implements Builder { clientUIState: clientUIState.build(), invoiceUIState: invoiceUIState.build(), filter: filter, + projectUIState: projectUIState.build(), paymentUIState: paymentUIState.build(), quoteUIState: quoteUIState.build()); } catch (_) { @@ -424,6 +449,8 @@ class UIStateBuilder implements Builder { _$failedField = 'invoiceUIState'; invoiceUIState.build(); + _$failedField = 'projectUIState'; + projectUIState.build(); _$failedField = 'paymentUIState'; paymentUIState.build(); _$failedField = 'quoteUIState'; diff --git a/lib/ui/app/app_drawer.dart b/lib/ui/app/app_drawer.dart index cbc7ff04b..94ba86a07 100644 --- a/lib/ui/app/app_drawer.dart +++ b/lib/ui/app/app_drawer.dart @@ -17,6 +17,8 @@ import 'package:redux/redux.dart'; import 'package:cached_network_image/cached_network_image.dart'; // STARTER: import - do not remove comment +import 'package:invoiceninja_flutter/redux/project/project_actions.dart'; + import 'package:invoiceninja_flutter/redux/payment/payment_actions.dart'; import 'package:invoiceninja_flutter/redux/quote/quote_actions.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -186,6 +188,12 @@ class AppDrawer extends StatelessWidget { }, ), // STARTER: menu - do not remove comment +ListTile( +leading: Icon(Icons.widgets), +title: Text('Projects'), +onTap: () => store.dispatch(ViewProjectList(context)), +), + DrawerTile( company: company, entityType: EntityType.payment, diff --git a/lib/ui/project/edit/project_edit.dart b/lib/ui/project/edit/project_edit.dart new file mode 100644 index 000000000..337ca00ba --- /dev/null +++ b/lib/ui/project/edit/project_edit.dart @@ -0,0 +1,112 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:invoiceninja_flutter/ui/app/form_card.dart'; +import 'package:invoiceninja_flutter/ui/project/edit/project_edit_vm.dart'; +import 'package:invoiceninja_flutter/ui/app/buttons/refresh_icon_button.dart'; +import 'package:invoiceninja_flutter/utils/localization.dart'; + +class ProjectEdit extends StatefulWidget { + final ProjectEditVM viewModel; + + const ProjectEdit({ + Key key, + @required this.viewModel, + }) : super(key: key); + + @override + _ProjectEditState createState() => _ProjectEditState(); +} + +class _ProjectEditState extends State { + static final GlobalKey _formKey = GlobalKey(); + + // STARTER: controllers - do not remove comment + + List _controllers = []; + + @override + void didChangeDependencies() { + + _controllers = [ + // STARTER: array - do not remove comment + ]; + + _controllers.forEach((controller) => controller.removeListener(_onChanged)); + + final project = widget.viewModel.project; + // STARTER: read value - do not remove comment + + _controllers.forEach((controller) => controller.addListener(_onChanged)); + + super.didChangeDependencies(); + } + + @override + void dispose() { + _controllers.forEach((controller) { + controller.removeListener(_onChanged); + controller.dispose(); + }); + + super.dispose(); + } + + void _onChanged() { + final project = widget.viewModel.project.rebuild((b) => b + // STARTER: set value - do not remove comment + ); + if (project != widget.viewModel.project) { + widget.viewModel.onChanged(project); + } + } + + @override + Widget build(BuildContext context) { + final viewModel = widget.viewModel; + final localization = AppLocalization.of(context); + final project = viewModel.project; + + return WillPopScope( + onWillPop: () async { + viewModel.onBackPressed(); + return true; + }, + child: Scaffold( + appBar: AppBar( + title: Text(viewModel.project.isNew + ? localization.newProject + : localization.editProject), + actions: [ + Builder(builder: (BuildContext context) { + RefreshIconButton( + icon: Icons.cloud_upload, + tooltip: localization.save, + isVisible: project.isDeleted, + isDirty: project.isNew || project != viewModel.origProject, + isSaving: viewModel.isSaving, + onPressed: () { + if (! _formKey.currentState.validate()) { + return; + } + viewModel.onSavePressed(context); + }, + ); + }), + ], + ), + body: Form( + key: _formKey, + child: ListView( + children: [ + FormCard( + children: [ + // STARTER: widgets - do not remove comment + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/ui/project/edit/project_edit_vm.dart b/lib/ui/project/edit/project_edit_vm.dart new file mode 100644 index 000000000..2227b642c --- /dev/null +++ b/lib/ui/project/edit/project_edit_vm.dart @@ -0,0 +1,84 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_redux/flutter_redux.dart'; +import 'package:invoiceninja_flutter/redux/ui/ui_actions.dart'; +import 'package:invoiceninja_flutter/ui/project/project_screen.dart'; +import 'package:redux/redux.dart'; +import 'package:invoiceninja_flutter/redux/project/project_actions.dart'; +import 'package:invoiceninja_flutter/data/models/project_model.dart'; +import 'package:invoiceninja_flutter/ui/project/edit/project_edit.dart'; +import 'package:invoiceninja_flutter/redux/app/app_state.dart'; +import 'package:invoiceninja_flutter/ui/app/icon_message.dart'; + +class ProjectEditScreen extends StatelessWidget { + static const String route = '/project/edit'; + ProjectEditScreen({Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return StoreConnector( + converter: (Store store) { + return ProjectEditVM.fromStore(store); + }, + builder: (context, viewModel) { + return ProjectEdit( + viewModel: viewModel, + ); + }, + ); + } +} + +class ProjectEditVM { + final ProjectEntity project; + final Function(ProjectEntity) onChanged; + final Function(BuildContext) onSavePressed; + final bool isSaving; + final ProjectEntity origProject; + final Function onBackPressed; + final bool isLoading; + + ProjectEditVM({ + @required this.project, + @required this.onChanged, + @required this.isSaving, + @required this.origProject, + @required this.onSavePressed, + @required this.onBackPressed, + @required this.isLoading, + }); + + factory ProjectEditVM.fromStore(Store store) { + final project = store.state.projectUIState.editing; + final state = store.state; + + return ProjectEditVM( + isLoading: state.isLoading, + isSaving: state.isSaving, + project: project, + origProject: state.projectState.map[project.id], + onChanged: (ProjectEntity project) { + store.dispatch(UpdateProject(project)); + }, + onBackPressed: () { + store.dispatch(UpdateCurrentRoute(ProjectScreen.route)); + }, + onSavePressed: (BuildContext context) { + final Completer completer = new Completer(); + store.dispatch(SaveProjectRequest(completer: completer, project: project)); + return completer.future.then((_) { + /* + Scaffold.of(context).showSnackBar(SnackBar( + content: IconMessage( + message: project.isNew + ? 'Successfully Created Project' + : 'Successfully Updated Project', + ), + duration: Duration(seconds: 3))); + */ + }); + }, + ); + } +} diff --git a/lib/ui/project/project_list.dart b/lib/ui/project/project_list.dart new file mode 100644 index 000000000..cff07b38d --- /dev/null +++ b/lib/ui/project/project_list.dart @@ -0,0 +1,119 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:invoiceninja_flutter/data/models/models.dart'; +import 'package:invoiceninja_flutter/ui/app/loading_indicator.dart'; +import 'package:invoiceninja_flutter/ui/app/snackbar_row.dart'; +import 'package:invoiceninja_flutter/ui/project/project_list_item.dart'; +import 'package:invoiceninja_flutter/ui/project/project_list_vm.dart'; +import 'package:invoiceninja_flutter/utils/localization.dart'; + +class ProjectList extends StatelessWidget { + final ProjectListVM viewModel; + + const ProjectList({ + Key key, + @required this.viewModel, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + if (!viewModel.isLoaded) { + return LoadingIndicator(); + } else if (viewModel.projectList.isEmpty) { + return Opacity( + opacity: 0.5, + child: Center( + child: Text( + AppLocalization.of(context).noRecordsFound, + style: TextStyle( + fontSize: 18.0, + ), + ), + ), + ); + } + + return _buildListView(context); + } + + void _showMenu(BuildContext context, ProjectEntity project) async { + if (project == null) { + return; + } + final user = viewModel.user; + final message = await showDialog( + context: context, + builder: (BuildContext context) => SimpleDialog(children: [ + user.canCreate(EntityType.project) + ? ListTile( + leading: Icon(Icons.control_point_duplicate), + title: Text(AppLocalization.of(context).clone), + onTap: () => viewModel.onEntityAction( + context, project, EntityAction.clone), + ) + : Container(), + Divider(), + user.canEditEntity(project) && !project.isActive + ? ListTile( + leading: Icon(Icons.restore), + title: Text(AppLocalization.of(context).restore), + onTap: () => viewModel.onEntityAction( + context, project, EntityAction.restore), + ) + : Container(), + user.canEditEntity(project) && project.isActive + ? ListTile( + leading: Icon(Icons.archive), + title: Text(AppLocalization.of(context).archive), + onTap: () => viewModel.onEntityAction( + context, project, EntityAction.archive), + ) + : Container(), + user.canEditEntity(project) && !project.isDeleted + ? ListTile( + leading: Icon(Icons.delete), + title: Text(AppLocalization.of(context).delete), + onTap: () => viewModel.onEntityAction( + context, project, EntityAction.delete), + ) + : Container(), + ])); + if (message != null) { + Scaffold.of(context).showSnackBar(SnackBar( + content: SnackBarRow( + message: message, + ))); + } + } + + Widget _buildListView(BuildContext context) { + return RefreshIndicator( + onRefresh: () => viewModel.onRefreshed(context), + child: ListView.builder( + itemCount: viewModel.projectList.length, + itemBuilder: (BuildContext context, index) { + final projectId = viewModel.projectList[index]; + final project = viewModel.projectMap[projectId]; + return Column(children: [ + ProjectListItem( + user: viewModel.user, + filter: viewModel.filter, + project: project, + onEntityAction: (EntityAction action) { + if (action == EntityAction.more) { + _showMenu(context, project); + } else { + viewModel.onEntityAction(context, project, action); + } + }, + onTap: () => viewModel.onProjectTap(context, project), + onLongPress: () => _showMenu(context, project), + ), + Divider( + height: 1.0, + ), + ]); + }), + ); + } +} diff --git a/lib/ui/project/project_list_item.dart b/lib/ui/project/project_list_item.dart new file mode 100644 index 000000000..551d731fc --- /dev/null +++ b/lib/ui/project/project_list_item.dart @@ -0,0 +1,84 @@ +import 'package:invoiceninja_flutter/ui/app/entity_state_label.dart'; +import 'package:invoiceninja_flutter/utils/formatting.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:invoiceninja_flutter/data/models/models.dart'; +import 'package:invoiceninja_flutter/ui/app/dismissible_entity.dart'; + +class ProjectListItem extends StatelessWidget { + final UserEntity user; + final Function(EntityAction) onEntityAction; + final GestureTapCallback onTap; + final GestureTapCallback onLongPress; + //final ValueChanged onCheckboxChanged; + final ProjectEntity project; + final String filter; + + static final projectItemKey = (int id) => Key('__project_item_${id}__'); + + const ProjectListItem({ + @required this.user, + @required this.onEntityAction, + @required this.onTap, + @required this.onLongPress, + //@required this.onCheckboxChanged, + @required this.project, + @required this.filter, + }); + + @override + Widget build(BuildContext context) { + final filterMatch = filter != null && filter.isNotEmpty + ? project.matchesFilterValue(filter) + : null; + final subtitle = filterMatch ?? project.privateNotes; + + return DismissibleEntity( + user: user, + entity: project, + onEntityAction: onEntityAction, + child: ListTile( + onTap: onTap, + onLongPress: onLongPress, + /* + leading: Checkbox( + //key: NinjaKeys.projectItemCheckbox(project.id), + value: true, + //onChanged: onCheckboxChanged, + onChanged: (value) { + return true; + }, + ), + */ + title: Container( + width: MediaQuery.of(context).size.width, + child: Row( + children: [ + Expanded( + child: Text( + project.name, + //key: NinjaKeys.clientItemClientKey(client.id), + style: Theme.of(context).textTheme.title, + ), + ), + Text(formatNumber(project.listDisplayAmount, context), + style: Theme.of(context).textTheme.title), + ], + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + subtitle != null && subtitle.isNotEmpty ? + Text( + subtitle, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ) : Container(), + EntityStateLabel(project), + ], + ), + ), + ); + } +} diff --git a/lib/ui/project/project_list_vm.dart b/lib/ui/project/project_list_vm.dart new file mode 100644 index 000000000..8bda9b2df --- /dev/null +++ b/lib/ui/project/project_list_vm.dart @@ -0,0 +1,135 @@ +import 'dart:async'; +import 'package:redux/redux.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_redux/flutter_redux.dart'; +import 'package:built_collection/built_collection.dart'; +import 'package:invoiceninja_flutter/utils/completers.dart'; +import 'package:invoiceninja_flutter/utils/localization.dart'; +import 'package:invoiceninja_flutter/redux/project/project_selectors.dart'; +import 'package:invoiceninja_flutter/data/models/models.dart'; +import 'package:invoiceninja_flutter/ui/project/project_list.dart'; +import 'package:invoiceninja_flutter/redux/app/app_state.dart'; +import 'package:invoiceninja_flutter/redux/project/project_actions.dart'; + +class ProjectListBuilder extends StatelessWidget { + const ProjectListBuilder({Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return StoreConnector( + converter: ProjectListVM.fromStore, + builder: (context, viewModel) { + return ProjectList( + viewModel: viewModel, + ); + }, + ); + } +} + +class ProjectListVM { + final UserEntity user; + final List projectList; + final BuiltMap projectMap; + final String filter; + final bool isLoading; + final bool isLoaded; + final Function(BuildContext, ProjectEntity) onProjectTap; + final Function(BuildContext, ProjectEntity, DismissDirection) onDismissed; + final Function(BuildContext) onRefreshed; + final Function(BuildContext, ProjectEntity, EntityAction) onEntityAction; + + ProjectListVM({ + @required this.user, + @required this.projectList, + @required this.projectMap, + @required this.filter, + @required this.isLoading, + @required this.isLoaded, + @required this.onProjectTap, + @required this.onDismissed, + @required this.onRefreshed, + @required this.onEntityAction, + }); + + static ProjectListVM fromStore(Store store) { + Future _handleRefresh(BuildContext context) { + if (store.state.isLoading) { + return Future(null); + } + final completer = snackBarCompleter( + context, AppLocalization.of(context).refreshComplete); + store.dispatch(LoadProjects(completer: completer, force: true)); + return completer.future; + } + + final state = store.state; + + return ProjectListVM( + user: state.user, + projectList: memoizedFilteredProjectList(state.projectState.map, + state.projectState.list, state.projectListState), + projectMap: state.projectState.map, + isLoading: state.isLoading, + isLoaded: state.projectState.isLoaded, + filter: state.projectUIState.listUIState.filter, + onProjectTap: (context, project) { + store.dispatch(EditProject(project: project, context: context)); + }, + onEntityAction: (context, project, action) { + switch (action) { + case EntityAction.clone: + Navigator.of(context).pop(); + store.dispatch( + EditProject(context: context, project: project.clone)); + break; + case EntityAction.restore: + store.dispatch(RestoreProjectRequest( + popCompleter( + context, AppLocalization.of(context).restoredProject), + project.id)); + break; + case EntityAction.archive: + store.dispatch(ArchiveProjectRequest( + popCompleter( + context, AppLocalization.of(context).archivedProject), + project.id)); + break; + case EntityAction.delete: + store.dispatch(DeleteProjectRequest( + popCompleter( + context, AppLocalization.of(context).deletedProject), + project.id)); + break; + } + }, + onRefreshed: (context) => _handleRefresh(context), + onDismissed: (BuildContext context, ProjectEntity project, + DismissDirection direction) { + final localization = AppLocalization.of(context); + if (direction == DismissDirection.endToStart) { + if (project.isDeleted || project.isArchived) { + store.dispatch(RestoreProjectRequest( + snackBarCompleter(context, localization.restoredProject), + project.id)); + } else { + store.dispatch(ArchiveProjectRequest( + snackBarCompleter(context, localization.archivedProject), + project.id)); + } + } else if (direction == DismissDirection.startToEnd) { + if (project.isDeleted) { + store.dispatch(RestoreProjectRequest( + snackBarCompleter(context, localization.restoredProject), + project.id)); + } else { + store.dispatch(DeleteProjectRequest( + snackBarCompleter(context, localization.deletedProject), + project.id)); + } + } + }); + } +} diff --git a/lib/ui/project/project_screen.dart b/lib/ui/project/project_screen.dart new file mode 100644 index 000000000..0ed069d3e --- /dev/null +++ b/lib/ui/project/project_screen.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_redux/flutter_redux.dart'; +import 'package:invoiceninja_flutter/redux/dashboard/dashboard_actions.dart'; +import 'package:invoiceninja_flutter/ui/app/list_filter.dart'; +import 'package:invoiceninja_flutter/ui/app/list_filter_button.dart'; +import 'package:invoiceninja_flutter/utils/localization.dart'; +import 'package:invoiceninja_flutter/redux/app/app_state.dart'; +import 'package:invoiceninja_flutter/data/models/models.dart'; +import 'package:invoiceninja_flutter/ui/project/project_list_vm.dart'; +import 'package:invoiceninja_flutter/redux/project/project_actions.dart'; +import 'package:invoiceninja_flutter/ui/app/app_drawer_vm.dart'; +import 'package:invoiceninja_flutter/ui/app/app_bottom_bar.dart'; +import 'package:invoiceninja_flutter/utils/keys.dart'; + +class ProjectScreen extends StatelessWidget { + static const String route = '/project'; + + @override + Widget build(BuildContext context) { + final store = StoreProvider.of(context); + final company = store.state.selectedCompany; + final user = company.user; + final localization = AppLocalization.of(context); + + return WillPopScope( + onWillPop: () async { + store.dispatch(ViewDashboard(context)); + return false; + }, + child: Scaffold( + appBar: AppBar( + title: ListFilter( + entityType: EntityType.project, + onFilterChanged: (value) { + store.dispatch(FilterProjects(value)); + }, + ), + actions: [ + ListFilterButton( + entityType: EntityType.project, + onFilterPressed: (String value) { + store.dispatch(FilterProjects(value)); + }, + ), + ], + ), + drawer: AppDrawerBuilder(), + body: ProjectListBuilder(), + bottomNavigationBar: AppBottomBar( + entityType: EntityType.project, + onSelectedSortField: (value) => store.dispatch(SortProjects(value)), + customValues1: company.getCustomFieldValues(CustomFieldType.project1, + excludeBlank: true), + customValues2: company.getCustomFieldValues(CustomFieldType.project2, + excludeBlank: true), + onSelectedCustom1: (value) => + store.dispatch(FilterProjectsByCustom1(value)), + onSelectedCustom2: (value) => + store.dispatch(FilterProjectsByCustom2(value)), + sortFields: [ + ProjectFields.projectKey, + ProjectFields.cost, + ProjectFields.updatedAt, + ], + onSelectedState: (EntityState state, value) { + store.dispatch(FilterProjectsByState(state)); + }, + ), + floatingActionButtonLocation: FloatingActionButtonLocation.endDocked, + floatingActionButton: user.canCreate(EntityType.project) + ? FloatingActionButton( + key: Key(ProjectKeys.projectScreenFABKeyString), + backgroundColor: Theme.of(context).primaryColorDark, + onPressed: () { + store.dispatch( + EditProject(project: ProjectEntity(), context: context)); + }, + child: Icon( + Icons.add, + color: Colors.white, + ), + tooltip: localization.newProject, + ) + : null, + ), + ); + } +} diff --git a/lib/ui/project/view/project_view.dart b/lib/ui/project/view/project_view.dart new file mode 100644 index 000000000..68aa9a658 --- /dev/null +++ b/lib/ui/project/view/project_view.dart @@ -0,0 +1,52 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:invoiceninja_flutter/ui/app/actions_menu_button.dart'; +import 'package:invoiceninja_flutter/ui/project/view/project_view_vm.dart'; +import 'package:invoiceninja_flutter/ui/app/form_card.dart'; + +class ProjectView extends StatefulWidget { + final ProjectViewVM viewModel; + + const ProjectView({ + Key key, + @required this.viewModel, + }) : super(key: key); + + @override + _ProjectViewState createState() => new _ProjectViewState(); +} + +class _ProjectViewState extends State { + @override + Widget build(BuildContext context) { + final viewModel = widget.viewModel; + final project = viewModel.project; + + return Scaffold( + appBar: AppBar( + title: Text(project.displayName), + actions: project.isNew + ? [] + : [ + IconButton( + icon: Icon(Icons.edit), + onPressed: () { + viewModel.onEditPressed(context); + }, + ), + ActionMenuButton( + user: viewModel.company.user, + isSaving: viewModel.isSaving, + entity: project, + onSelected: viewModel.onActionSelected, + ), + ], + ), + body: FormCard( + children: [ + // STARTER: widgets - do not remove comment + ] + ), + ); + } +} diff --git a/lib/ui/project/view/project_view_vm.dart b/lib/ui/project/view/project_view_vm.dart new file mode 100644 index 000000000..949fa4e9a --- /dev/null +++ b/lib/ui/project/view/project_view_vm.dart @@ -0,0 +1,82 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:redux/redux.dart'; +import 'package:flutter_redux/flutter_redux.dart'; +import 'package:invoiceninja_flutter/redux/project/project_actions.dart'; +import 'package:invoiceninja_flutter/data/models/project_model.dart'; +import 'package:invoiceninja_flutter/data/models/models.dart'; +import 'package:invoiceninja_flutter/ui/project/view/project_view.dart'; +import 'package:invoiceninja_flutter/redux/app/app_state.dart'; +import 'package:invoiceninja_flutter/ui/app/icon_message.dart'; + +class ProjectViewScreen extends StatelessWidget { + static const String route = '/project/view'; + const ProjectViewScreen({Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return StoreConnector( + converter: (Store store) { + return ProjectViewVM.fromStore(store); + }, + builder: (context, vm) { + return ProjectView( + viewModel: vm, + ); + }, + ); + } +} + +class ProjectViewVM { + final ProjectEntity project; + final CompanyEntity company; + final Function(BuildContext, EntityAction) onActionSelected; + final Function(BuildContext) onEditPressed; + final bool isSaving; + final bool isLoading; + final bool isDirty; + + ProjectViewVM({ + @required this.project, + @required this.company, + @required this.onActionSelected, + @required this.onEditPressed, + @required this.isSaving, + @required this.isLoading, + @required this.isDirty, + }); + + factory ProjectViewVM.fromStore(Store store) { + final state = store.state; + final payment = state.projectState.map[state.projectUIState.selectedId]; + + return ProjectViewVM( + isLoading: store.state.isLoading, + project: project, + onEditPressed: (BuildContext context) { + store.dispatch(EditProject(project: project, context: context)); + }, + onActionSelected: (BuildContext context, EntityAction action) { + final localization = AppLocalization.of(context); + switch (action) { + case EntityAction.archive: + store.dispatch(ArchiveProjectRequest( + popCompleter(context, localization.archivedProject), + project.id)); + break; + case EntityAction.delete: + store.dispatch(DeleteProjectRequest( + popCompleter(context, localization.deletedProject), + project.id)); + break; + case EntityAction.restore: + store.dispatch(RestoreProjectRequest( + snackBarCompleter(context, localization.restoredProject), + project.id)); + break; + } + }); + } +} diff --git a/lib/utils/localization.dart b/lib/utils/localization.dart index 698245115..64e87c474 100644 --- a/lib/utils/localization.dart +++ b/lib/utils/localization.dart @@ -20,6 +20,7 @@ class AppLocalization { static final Map> _localizedValues = { 'en': { + 'new_project': 'New Project', 'thank_you_for_using_our_app': 'Thank you for using our app!', 'if_you_like_it': 'If you like it please', 'click_here': 'click here', @@ -9850,6 +9851,8 @@ class AppLocalization { }, }; + String get newProject => _localizedValues[locale.languageCode]['new_project']; + String get thankYouForUsingOurApp => _localizedValues[locale.languageCode]['thank_you_for_using_our_app']; diff --git a/stubs/data/repositories/stub_repository b/stubs/data/repositories/stub_repository index b52048e11..178a17987 100644 --- a/stubs/data/repositories/stub_repository +++ b/stubs/data/repositories/stub_repository @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:core'; import 'package:built_collection/built_collection.dart'; +import 'package:invoiceninja_flutter/constants.dart'; import 'package:invoiceninja_flutter/data/models/serializers.dart'; import 'package:invoiceninja_flutter/redux/auth/auth_state.dart'; import 'package:invoiceninja_flutter/data/models/models.dart'; diff --git a/stubs/redux/stub/stub_reducer b/stubs/redux/stub/stub_reducer index fae855d22..7844721b6 100644 --- a/stubs/redux/stub/stub_reducer +++ b/stubs/redux/stub/stub_reducer @@ -46,11 +46,11 @@ final stubListReducer = combineReducers([ TypedReducer(_filterStubs), TypedReducer(_filterStubsByCustom1), TypedReducer(_filterStubsByCustom2), - TypedReducer(_filterStubsByClient), + TypedReducer(_filterStubsByClient), ]); ListUIState _filterStubsByClient( - ListUIState stubListState, FilterStubsByClient action) { + ListUIState stubListState, FilterStubsByEntity action) { return stubListState.rebuild((b) => b ..filterEntityId = action.entityId ..filterEntityType = action.entityType); diff --git a/stubs/ui/stub/edit/stub_edit b/stubs/ui/stub/edit/stub_edit index 91a08e5a9..d01173b67 100644 --- a/stubs/ui/stub/edit/stub_edit +++ b/stubs/ui/stub/edit/stub_edit @@ -51,7 +51,7 @@ class _StubEditState extends State { super.dispose(); } - _onChanged() { + void _onChanged() { final stub = widget.viewModel.stub.rebuild((b) => b // STARTER: set value - do not remove comment ); @@ -64,6 +64,7 @@ class _StubEditState extends State { Widget build(BuildContext context) { final viewModel = widget.viewModel; final localization = AppLocalization.of(context); + final stub = viewModel.stub; return WillPopScope( onWillPop: () async { @@ -76,18 +77,19 @@ class _StubEditState extends State { ? localization.newStub : localization.editStub), actions: [ - Builder(builder: (BuildContext context) { - return SaveIconButton( - isLoading: viewModel.isLoading, - onPressed: () { - if (!_formKey.currentState.validate()) { - return; - } - - viewModel.onSavePressed(context); - }, - ); - }), + RefreshIconButton( + icon: Icons.cloud_upload, + tooltip: localization.save, + isVisible: !stub.isDeleted, + isDirty: stub.isNew || stub != viewModel.origStub, + isSaving: viewModel.isSaving, + onPressed: () { + if (! _formKey.currentState.validate()) { + return; + } + viewModel.onSavePressed(context); + }, + ) ], ), body: Form( diff --git a/stubs/ui/stub/edit/stub_edit_vm b/stubs/ui/stub/edit/stub_edit_vm index b5f30c4f4..af2f0deea 100644 --- a/stubs/ui/stub/edit/stub_edit_vm +++ b/stubs/ui/stub/edit/stub_edit_vm @@ -40,6 +40,8 @@ class StubEditVM { StubEditVM({ @required this.stub, @required this.onChanged, + @required this.isSaving, + @required this.origStub, @required this.onSavePressed, @required this.onBackPressed, @required this.isLoading, @@ -47,9 +49,12 @@ class StubEditVM { factory StubEditVM.fromStore(Store store) { final stub = store.state.stubUIState.editing; + final state = store.state; return StubEditVM( - isLoading: store.state.isLoading, + isLoading: state.isLoading, + isSaving: state.isSaving, + origStub: state.stubState.map[stub.id], stub: stub, onChanged: (StubEntity stub) { store.dispatch(UpdateStub(stub)); diff --git a/stubs/ui/stub/stub_list b/stubs/ui/stub/stub_list index 3065fd73d..e25628bc1 100644 --- a/stubs/ui/stub/stub_list +++ b/stubs/ui/stub/stub_list @@ -99,8 +99,13 @@ class StubList extends StatelessWidget { user: viewModel.user, filter: viewModel.filter, stub: stub, - onDismissed: (DismissDirection direction) => - viewModel.onDismissed(context, stub, direction), + onEntityAction: (EntityAction action) { + if (action == EntityAction.more) { + _showMenu(context, stub); + } else { + viewModel.onEntityAction(context, stub, action); + } + }, onTap: () => viewModel.onStubTap(context, stub), onLongPress: () => _showMenu(context, stub), ), diff --git a/stubs/ui/stub/stub_list_item b/stubs/ui/stub/stub_list_item index 7a69fbf70..ca0ce0811 100644 --- a/stubs/ui/stub/stub_list_item +++ b/stubs/ui/stub/stub_list_item @@ -7,7 +7,7 @@ import 'package:invoiceninja_flutter/ui/app/dismissible_entity.dart'; class StubListItem extends StatelessWidget { final UserEntity user; - final DismissDirectionCallback onDismissed; + final Function(EntityAction) onEntityAction; final GestureTapCallback onTap; final GestureTapCallback onLongPress; //final ValueChanged onCheckboxChanged; @@ -18,7 +18,7 @@ class StubListItem extends StatelessWidget { const StubListItem({ @required this.user, - @required this.onDismissed, + @required this.onEntityAction, @required this.onTap, @required this.onLongPress, //@required this.onCheckboxChanged, @@ -56,12 +56,12 @@ class StubListItem extends StatelessWidget { children: [ Expanded( child: Text( - stub.stubKey, + stub.name, //key: NinjaKeys.clientItemClientKey(client.id), style: Theme.of(context).textTheme.title, ), ), - Text(formatNumber(stub.cost, context), + Text(formatNumber(stub.listDisplayAmount, context), style: Theme.of(context).textTheme.title), ], ),