diff --git a/lib/data/models/expense_model.dart b/lib/data/models/expense_model.dart index 3012fe364..3eb1fde9a 100644 --- a/lib/data/models/expense_model.dart +++ b/lib/data/models/expense_model.dart @@ -72,7 +72,7 @@ abstract class ExpenseEntity extends Object shouldBeInvoiced: false, transactionId: '', transactionReference: '', - bankId: '', + bankId: 0, expenseCurrencyId: 0, exchangeCurrencyId: 0, amount: 0.0, @@ -120,7 +120,7 @@ abstract class ExpenseEntity extends Object String get transactionReference; @BuiltValueField(wireName: 'bank_id') - String get bankId; + int get bankId; @BuiltValueField(wireName: 'expense_currency_id') int get expenseCurrencyId; @@ -136,7 +136,7 @@ abstract class ExpenseEntity extends Object @BuiltValueField(wireName: 'exchange_rate') double get exchangeRate; - @BuiltValueField(wireName: 'invoiceCurrencyId') + @BuiltValueField(wireName: 'invoice_currency_id') int get invoiceCurrencyId; @BuiltValueField(wireName: 'tax_name1') @@ -167,7 +167,8 @@ abstract class ExpenseEntity extends Object @BuiltValueField(wireName: 'expense_category') BuiltList get expenseCategories; - List getEntityActions({UserEntity user, ClientEntity client}) { + List getEntityActions( + {UserEntity user, ClientEntity client, bool includeEdit = false}) { final actions = []; return actions..addAll(getBaseActions(user: user)); diff --git a/lib/data/models/expense_model.g.dart b/lib/data/models/expense_model.g.dart index 888514eea..2a8140231 100644 --- a/lib/data/models/expense_model.g.dart +++ b/lib/data/models/expense_model.g.dart @@ -145,8 +145,7 @@ class _$ExpenseEntitySerializer implements StructuredSerializer { serializers.serialize(object.transactionReference, specifiedType: const FullType(String)), 'bank_id', - serializers.serialize(object.bankId, - specifiedType: const FullType(String)), + serializers.serialize(object.bankId, specifiedType: const FullType(int)), 'expense_currency_id', serializers.serialize(object.expenseCurrencyId, specifiedType: const FullType(int)), @@ -162,7 +161,7 @@ class _$ExpenseEntitySerializer implements StructuredSerializer { 'exchange_rate', serializers.serialize(object.exchangeRate, specifiedType: const FullType(double)), - 'invoiceCurrencyId', + 'invoice_currency_id', serializers.serialize(object.invoiceCurrencyId, specifiedType: const FullType(int)), 'tax_name1', @@ -267,7 +266,7 @@ class _$ExpenseEntitySerializer implements StructuredSerializer { break; case 'bank_id': result.bankId = serializers.deserialize(value, - specifiedType: const FullType(String)) as String; + specifiedType: const FullType(int)) as int; break; case 'expense_currency_id': result.expenseCurrencyId = serializers.deserialize(value, @@ -289,7 +288,7 @@ class _$ExpenseEntitySerializer implements StructuredSerializer { result.exchangeRate = serializers.deserialize(value, specifiedType: const FullType(double)) as double; break; - case 'invoiceCurrencyId': + case 'invoice_currency_id': result.invoiceCurrencyId = serializers.deserialize(value, specifiedType: const FullType(int)) as int; break; @@ -663,7 +662,7 @@ class _$ExpenseEntity extends ExpenseEntity { @override final String transactionReference; @override - final String bankId; + final int bankId; @override final int expenseCurrencyId; @override @@ -948,9 +947,9 @@ class ExpenseEntityBuilder set transactionReference(String transactionReference) => _$this._transactionReference = transactionReference; - String _bankId; - String get bankId => _$this._bankId; - set bankId(String bankId) => _$this._bankId = bankId; + int _bankId; + int get bankId => _$this._bankId; + set bankId(int bankId) => _$this._bankId = bankId; int _expenseCurrencyId; int get expenseCurrencyId => _$this._expenseCurrencyId; diff --git a/lib/data/models/serializers.dart b/lib/data/models/serializers.dart index 07a5a901e..b44de226d 100644 --- a/lib/data/models/serializers.dart +++ b/lib/data/models/serializers.dart @@ -23,6 +23,8 @@ 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/redux/expense/expense_state.dart'; + import 'package:invoiceninja_flutter/redux/vendor/vendor_state.dart'; import 'package:invoiceninja_flutter/redux/task/task_state.dart'; @@ -78,6 +80,8 @@ part 'serializers.g.dart'; TimezoneItemResponse, TimezoneListResponse, // STARTER: serializers - do not remove comment + ExpenseEntity, + VendorEntity, TaskEntity, diff --git a/lib/data/models/serializers.g.dart b/lib/data/models/serializers.g.dart index c5c17fa2d..3708644a6 100644 --- a/lib/data/models/serializers.g.dart +++ b/lib/data/models/serializers.g.dart @@ -59,6 +59,8 @@ Serializers _$serializers = (new Serializers().toBuilder() ..add(ExpenseEntity.serializer) ..add(ExpenseItemResponse.serializer) ..add(ExpenseListResponse.serializer) + ..add(ExpenseState.serializer) + ..add(ExpenseUIState.serializer) ..add(FrequencyEntity.serializer) ..add(FrequencyItemResponse.serializer) ..add(FrequencyListResponse.serializer) @@ -354,6 +356,8 @@ Serializers _$serializers = (new Serializers().toBuilder() ..addBuilderFactory(const FullType(BuiltMap, const [const FullType(int), const FullType(CountryEntity)]), () => new MapBuilder()) ..addBuilderFactory(const FullType(BuiltMap, const [const FullType(int), const FullType(InvoiceStatusEntity)]), () => new MapBuilder()) ..addBuilderFactory(const FullType(BuiltMap, const [const FullType(int), const FullType(FrequencyEntity)]), () => new MapBuilder()) + ..addBuilderFactory(const FullType(BuiltMap, const [const FullType(int), const FullType(ExpenseEntity)]), () => new MapBuilder()) + ..addBuilderFactory(const FullType(BuiltList, const [const FullType(int)]), () => new ListBuilder()) ..addBuilderFactory(const FullType(BuiltMap, const [const FullType(int), const FullType(InvoiceEntity)]), () => new MapBuilder()) ..addBuilderFactory(const FullType(BuiltList, const [const FullType(int)]), () => new ListBuilder()) ..addBuilderFactory(const FullType(BuiltMap, const [const FullType(int), const FullType(InvoiceEntity)]), () => new MapBuilder()) diff --git a/lib/data/repositories/expense_repository.dart b/lib/data/repositories/expense_repository.dart new file mode 100644 index 000000000..c278eab40 --- /dev/null +++ b/lib/data/repositories/expense_repository.dart @@ -0,0 +1,67 @@ +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 ExpenseRepository { + const ExpenseRepository({ + this.webClient = const WebClient(), + }); + + final WebClient webClient; + + Future loadItem( + CompanyEntity company, AuthState auth, int entityId) async { + final dynamic response = + await webClient.get('${auth.url}/expenses/$entityId', company.token); + + final ExpenseItemResponse expenseResponse = + serializers.deserializeWith(ExpenseItemResponse.serializer, response); + + return expenseResponse.data; + } + + Future> loadList( + CompanyEntity company, AuthState auth, int updatedAt) async { + String url = auth.url + '/expenses?'; + + if (updatedAt > 0) { + url += '&updated_at=${updatedAt - kUpdatedAtBufferSeconds}'; + } + + final dynamic response = await webClient.get(url, company.token); + + final ExpenseListResponse expenseResponse = + serializers.deserializeWith(ExpenseListResponse.serializer, response); + + return expenseResponse.data; + } + + Future saveData( + CompanyEntity company, AuthState auth, ExpenseEntity expense, + [EntityAction action]) async { + final data = serializers.serializeWith(ExpenseEntity.serializer, expense); + dynamic response; + + if (expense.isNew) { + response = await webClient.post( + auth.url + '/expenses', company.token, json.encode(data)); + } else { + var url = auth.url + '/expenses/' + expense.id.toString(); + if (action != null) { + url += '?action=' + action.toString(); + } + response = await webClient.put(url, company.token, json.encode(data)); + } + + final ExpenseItemResponse expenseResponse = + serializers.deserializeWith(ExpenseItemResponse.serializer, response); + + return expenseResponse.data; + } +} diff --git a/lib/main.dart b/lib/main.dart index 9108dc346..29111b643 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -43,6 +43,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/expense/expense_screen.dart'; +import 'package:invoiceninja_flutter/ui/expense/edit/expense_edit_vm.dart'; +import 'package:invoiceninja_flutter/ui/expense/view/expense_view_vm.dart'; +import 'package:invoiceninja_flutter/redux/expense/expense_actions.dart'; +import 'package:invoiceninja_flutter/redux/expense/expense_middleware.dart'; + import 'package:invoiceninja_flutter/ui/vendor/vendor_screen.dart'; import 'package:invoiceninja_flutter/ui/vendor/edit/vendor_edit_vm.dart'; import 'package:invoiceninja_flutter/ui/vendor/view/vendor_view_vm.dart'; @@ -97,6 +103,7 @@ void main() async { ..addAll(createStoreInvoicesMiddleware()) ..addAll(createStorePersistenceMiddleware()) // STARTER: middleware - do not remove comment + ..addAll(createStoreExpensesMiddleware()) ..addAll(createStoreVendorsMiddleware()) ..addAll(createStoreTasksMiddleware()) ..addAll(createStoreProjectsMiddleware()) @@ -306,6 +313,13 @@ class InvoiceNinjaAppState extends State { InvoiceEditScreen.route: (context) => InvoiceEditScreen(), InvoiceEmailScreen.route: (context) => InvoiceEmailScreen(), // STARTER: routes - do not remove comment + ExpenseScreen.route: (context) { + widget.store.dispatch(LoadExpenses()); + return ExpenseScreen(); + }, + ExpenseViewScreen.route: (context) => ExpenseViewScreen(), + ExpenseEditScreen.route: (context) => ExpenseEditScreen(), + VendorScreen.route: (context) { widget.store.dispatch(LoadVendors()); return VendorScreen(); diff --git a/lib/redux/app/app_state.dart b/lib/redux/app/app_state.dart index 2b2e23243..378443df3 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/expense/expense_state.dart'; + import 'package:invoiceninja_flutter/redux/vendor/vendor_state.dart'; import 'package:invoiceninja_flutter/redux/task/task_state.dart'; @@ -109,6 +111,9 @@ abstract class AppState implements Built { case EntityType.invoice: return invoiceUIState; // STARTER: states switch - do not remove comment + case EntityType.expense: + return expenseUIState; + case EntityType.vendor: return vendorUIState; @@ -150,6 +155,10 @@ abstract class AppState implements Built { ListUIState get invoiceListState => uiState.invoiceUIState.listUIState; // STARTER: state getters - do not remove comment + ExpenseState get expenseState => selectedCompanyState.expenseState; + ListUIState get expenseListState => uiState.expenseUIState.listUIState; + ExpenseUIState get expenseUIState => uiState.expenseUIState; + VendorState get vendorState => selectedCompanyState.vendorState; ListUIState get vendorListState => uiState.vendorUIState.listUIState; VendorUIState get vendorUIState => uiState.vendorUIState; diff --git a/lib/redux/company/company_reducer.dart b/lib/redux/company/company_reducer.dart index 426ae876a..50ac21e34 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/expense/expense_reducer.dart'; + import 'package:invoiceninja_flutter/redux/vendor/vendor_reducer.dart'; import 'package:invoiceninja_flutter/redux/task/task_reducer.dart'; @@ -31,6 +33,7 @@ CompanyState companyReducer(CompanyState state, dynamic action) { ..productState.replace(productsReducer(state.productState, action)) ..invoiceState.replace(invoicesReducer(state.invoiceState, action)) // STARTER: reducer - do not remove comment + ..expenseState.replace(expensesReducer(state.expenseState, action)) ..vendorState.replace(vendorsReducer(state.vendorState, action)) ..taskState.replace(tasksReducer(state.taskState, action)) ..projectState.replace(projectsReducer(state.projectState, action)) diff --git a/lib/redux/company/company_state.dart b/lib/redux/company/company_state.dart index d42df6f15..674f760e4 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/expense/expense_state.dart'; + import 'package:invoiceninja_flutter/redux/vendor/vendor_state.dart'; import 'package:invoiceninja_flutter/redux/task/task_state.dart'; @@ -29,6 +31,8 @@ abstract class CompanyState clientState: ClientState(), invoiceState: InvoiceState(), // STARTER: constructor - do not remove comment + expenseState: ExpenseState(), + vendorState: VendorState(), taskState: TaskState(), @@ -52,6 +56,8 @@ abstract class CompanyState InvoiceState get invoiceState; // STARTER: fields - do not remove comment + ExpenseState get expenseState; + VendorState get vendorState; TaskState get taskState; diff --git a/lib/redux/company/company_state.g.dart b/lib/redux/company/company_state.g.dart index 53fc1676d..c4bf043bf 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)), + 'expenseState', + serializers.serialize(object.expenseState, + specifiedType: const FullType(ExpenseState)), 'vendorState', serializers.serialize(object.vendorState, specifiedType: const FullType(VendorState)), @@ -101,6 +104,10 @@ class _$CompanyStateSerializer implements StructuredSerializer { result.invoiceState.replace(serializers.deserialize(value, specifiedType: const FullType(InvoiceState)) as InvoiceState); break; + case 'expenseState': + result.expenseState.replace(serializers.deserialize(value, + specifiedType: const FullType(ExpenseState)) as ExpenseState); + break; case 'vendorState': result.vendorState.replace(serializers.deserialize(value, specifiedType: const FullType(VendorState)) as VendorState); @@ -140,6 +147,8 @@ class _$CompanyState extends CompanyState { @override final InvoiceState invoiceState; @override + final ExpenseState expenseState; + @override final VendorState vendorState; @override final TaskState taskState; @@ -159,6 +168,7 @@ class _$CompanyState extends CompanyState { this.productState, this.clientState, this.invoiceState, + this.expenseState, this.vendorState, this.taskState, this.projectState, @@ -177,6 +187,9 @@ class _$CompanyState extends CompanyState { if (invoiceState == null) { throw new BuiltValueNullFieldError('CompanyState', 'invoiceState'); } + if (expenseState == null) { + throw new BuiltValueNullFieldError('CompanyState', 'expenseState'); + } if (vendorState == null) { throw new BuiltValueNullFieldError('CompanyState', 'vendorState'); } @@ -210,6 +223,7 @@ class _$CompanyState extends CompanyState { productState == other.productState && clientState == other.clientState && invoiceState == other.invoiceState && + expenseState == other.expenseState && vendorState == other.vendorState && taskState == other.taskState && projectState == other.projectState && @@ -227,11 +241,13 @@ class _$CompanyState extends CompanyState { $jc( $jc( $jc( - $jc($jc(0, company.hashCode), - dashboardState.hashCode), - productState.hashCode), - clientState.hashCode), - invoiceState.hashCode), + $jc( + $jc($jc(0, company.hashCode), + dashboardState.hashCode), + productState.hashCode), + clientState.hashCode), + invoiceState.hashCode), + expenseState.hashCode), vendorState.hashCode), taskState.hashCode), projectState.hashCode), @@ -247,6 +263,7 @@ class _$CompanyState extends CompanyState { ..add('productState', productState) ..add('clientState', clientState) ..add('invoiceState', invoiceState) + ..add('expenseState', expenseState) ..add('vendorState', vendorState) ..add('taskState', taskState) ..add('projectState', projectState) @@ -289,6 +306,12 @@ class CompanyStateBuilder set invoiceState(InvoiceStateBuilder invoiceState) => _$this._invoiceState = invoiceState; + ExpenseStateBuilder _expenseState; + ExpenseStateBuilder get expenseState => + _$this._expenseState ??= new ExpenseStateBuilder(); + set expenseState(ExpenseStateBuilder expenseState) => + _$this._expenseState = expenseState; + VendorStateBuilder _vendorState; VendorStateBuilder get vendorState => _$this._vendorState ??= new VendorStateBuilder(); @@ -327,6 +350,7 @@ class CompanyStateBuilder _productState = _$v.productState?.toBuilder(); _clientState = _$v.clientState?.toBuilder(); _invoiceState = _$v.invoiceState?.toBuilder(); + _expenseState = _$v.expenseState?.toBuilder(); _vendorState = _$v.vendorState?.toBuilder(); _taskState = _$v.taskState?.toBuilder(); _projectState = _$v.projectState?.toBuilder(); @@ -361,6 +385,7 @@ class CompanyStateBuilder productState: productState.build(), clientState: clientState.build(), invoiceState: invoiceState.build(), + expenseState: expenseState.build(), vendorState: vendorState.build(), taskState: taskState.build(), projectState: projectState.build(), @@ -379,6 +404,8 @@ class CompanyStateBuilder clientState.build(); _$failedField = 'invoiceState'; invoiceState.build(); + _$failedField = 'expenseState'; + expenseState.build(); _$failedField = 'vendorState'; vendorState.build(); _$failedField = 'taskState'; diff --git a/lib/redux/expense/expense_actions.dart b/lib/redux/expense/expense_actions.dart new file mode 100644 index 000000000..c63fbea1d --- /dev/null +++ b/lib/redux/expense/expense_actions.dart @@ -0,0 +1,223 @@ +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 ViewExpenseList implements PersistUI { + ViewExpenseList(this.context); + + final BuildContext context; +} + +class ViewExpense implements PersistUI { + ViewExpense({this.expenseId, this.context}); + + final int expenseId; + final BuildContext context; +} + +class EditExpense implements PersistUI { + EditExpense( + {this.expense, this.context, this.completer, this.trackRoute = true}); + + final ExpenseEntity expense; + final BuildContext context; + final Completer completer; + final bool trackRoute; +} + +class UpdateExpense implements PersistUI { + UpdateExpense(this.expense); + + final ExpenseEntity expense; +} + +class LoadExpense { + LoadExpense({this.completer, this.expenseId, this.loadActivities = false}); + + final Completer completer; + final int expenseId; + final bool loadActivities; +} + +class LoadExpenseActivity { + LoadExpenseActivity({this.completer, this.expenseId}); + + final Completer completer; + final int expenseId; +} + +class LoadExpenses { + LoadExpenses({this.completer, this.force = false}); + + final Completer completer; + final bool force; +} + +class LoadExpenseRequest implements StartLoading {} + +class LoadExpenseFailure implements StopLoading { + LoadExpenseFailure(this.error); + + final dynamic error; + + @override + String toString() { + return 'LoadExpenseFailure{error: $error}'; + } +} + +class LoadExpenseSuccess implements StopLoading, PersistData { + LoadExpenseSuccess(this.expense); + + final ExpenseEntity expense; + + @override + String toString() { + return 'LoadExpenseSuccess{expense: $expense}'; + } +} + +class LoadExpensesRequest implements StartLoading {} + +class LoadExpensesFailure implements StopLoading { + LoadExpensesFailure(this.error); + + final dynamic error; + + @override + String toString() { + return 'LoadExpensesFailure{error: $error}'; + } +} + +class LoadExpensesSuccess implements StopLoading, PersistData { + LoadExpensesSuccess(this.expenses); + + final BuiltList expenses; + + @override + String toString() { + return 'LoadExpensesSuccess{expenses: $expenses}'; + } +} + +class SaveExpenseRequest implements StartSaving { + SaveExpenseRequest({this.completer, this.expense}); + + final Completer completer; + final ExpenseEntity expense; +} + +class SaveExpenseSuccess implements StopSaving, PersistData, PersistUI { + SaveExpenseSuccess(this.expense); + + final ExpenseEntity expense; +} + +class AddExpenseSuccess implements StopSaving, PersistData, PersistUI { + AddExpenseSuccess(this.expense); + + final ExpenseEntity expense; +} + +class SaveExpenseFailure implements StopSaving { + SaveExpenseFailure(this.error); + + final Object error; +} + +class ArchiveExpenseRequest implements StartSaving { + ArchiveExpenseRequest(this.completer, this.expenseId); + + final Completer completer; + final int expenseId; +} + +class ArchiveExpenseSuccess implements StopSaving, PersistData { + ArchiveExpenseSuccess(this.expense); + + final ExpenseEntity expense; +} + +class ArchiveExpenseFailure implements StopSaving { + ArchiveExpenseFailure(this.expense); + + final ExpenseEntity expense; +} + +class DeleteExpenseRequest implements StartSaving { + DeleteExpenseRequest(this.completer, this.expenseId); + + final Completer completer; + final int expenseId; +} + +class DeleteExpenseSuccess implements StopSaving, PersistData { + DeleteExpenseSuccess(this.expense); + + final ExpenseEntity expense; +} + +class DeleteExpenseFailure implements StopSaving { + DeleteExpenseFailure(this.expense); + + final ExpenseEntity expense; +} + +class RestoreExpenseRequest implements StartSaving { + RestoreExpenseRequest(this.completer, this.expenseId); + + final Completer completer; + final int expenseId; +} + +class RestoreExpenseSuccess implements StopSaving, PersistData { + RestoreExpenseSuccess(this.expense); + + final ExpenseEntity expense; +} + +class RestoreExpenseFailure implements StopSaving { + RestoreExpenseFailure(this.expense); + + final ExpenseEntity expense; +} + +class FilterExpenses { + FilterExpenses(this.filter); + + final String filter; +} + +class SortExpenses implements PersistUI { + SortExpenses(this.field); + + final String field; +} + +class FilterExpensesByState implements PersistUI { + FilterExpensesByState(this.state); + + final EntityState state; +} + +class FilterExpensesByCustom1 implements PersistUI { + FilterExpensesByCustom1(this.value); + + final String value; +} + +class FilterExpensesByCustom2 implements PersistUI { + FilterExpensesByCustom2(this.value); + + final String value; +} + +class FilterExpensesByEntity implements PersistUI { + FilterExpensesByEntity({this.entityId, this.entityType}); + + final int entityId; + final EntityType entityType; +} diff --git a/lib/redux/expense/expense_middleware.dart b/lib/redux/expense/expense_middleware.dart new file mode 100644 index 000000000..a9eb6d3d3 --- /dev/null +++ b/lib/redux/expense/expense_middleware.dart @@ -0,0 +1,234 @@ +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/ui/ui_actions.dart'; +import 'package:invoiceninja_flutter/ui/expense/expense_screen.dart'; +import 'package:invoiceninja_flutter/ui/expense/edit/expense_edit_vm.dart'; +import 'package:invoiceninja_flutter/ui/expense/view/expense_view_vm.dart'; +import 'package:invoiceninja_flutter/redux/expense/expense_actions.dart'; +import 'package:invoiceninja_flutter/redux/app/app_state.dart'; +import 'package:invoiceninja_flutter/data/repositories/expense_repository.dart'; + +List> createStoreExpensesMiddleware([ + ExpenseRepository repository = const ExpenseRepository(), +]) { + final viewExpenseList = _viewExpenseList(); + final viewExpense = _viewExpense(); + final editExpense = _editExpense(); + final loadExpenses = _loadExpenses(repository); + final loadExpense = _loadExpense(repository); + final saveExpense = _saveExpense(repository); + final archiveExpense = _archiveExpense(repository); + final deleteExpense = _deleteExpense(repository); + final restoreExpense = _restoreExpense(repository); + + return [ + TypedMiddleware(viewExpenseList), + TypedMiddleware(viewExpense), + TypedMiddleware(editExpense), + TypedMiddleware(loadExpenses), + TypedMiddleware(loadExpense), + TypedMiddleware(saveExpense), + TypedMiddleware(archiveExpense), + TypedMiddleware(deleteExpense), + TypedMiddleware(restoreExpense), + ]; +} + +Middleware _editExpense() { + return (Store store, dynamic action, NextDispatcher next) async { + next(action); + + store.dispatch(UpdateCurrentRoute(ExpenseEditScreen.route)); + final expense = + await Navigator.of(action.context).pushNamed(ExpenseEditScreen.route); + + if (action.completer != null && expense != null) { + action.completer.complete(expense); + } + }; +} + +Middleware _viewExpense() { + return (Store store, dynamic action, NextDispatcher next) async { + next(action); + + store.dispatch(UpdateCurrentRoute(ExpenseViewScreen.route)); + Navigator.of(action.context).pushNamed(ExpenseViewScreen.route); + }; +} + +Middleware _viewExpenseList() { + return (Store store, dynamic action, NextDispatcher next) { + next(action); + + store.dispatch(UpdateCurrentRoute(ExpenseScreen.route)); + + Navigator.of(action.context).pushNamedAndRemoveUntil( + ExpenseScreen.route, (Route route) => false); + }; +} + +Middleware _archiveExpense(ExpenseRepository repository) { + return (Store store, dynamic action, NextDispatcher next) { + final origExpense = store.state.expenseState.map[action.expenseId]; + repository + .saveData(store.state.selectedCompany, store.state.authState, + origExpense, EntityAction.archive) + .then((ExpenseEntity expense) { + store.dispatch(ArchiveExpenseSuccess(expense)); + if (action.completer != null) { + action.completer.complete(null); + } + }).catchError((Object error) { + print(error); + store.dispatch(ArchiveExpenseFailure(origExpense)); + if (action.completer != null) { + action.completer.completeError(error); + } + }); + + next(action); + }; +} + +Middleware _deleteExpense(ExpenseRepository repository) { + return (Store store, dynamic action, NextDispatcher next) { + final origExpense = store.state.expenseState.map[action.expenseId]; + repository + .saveData(store.state.selectedCompany, store.state.authState, + origExpense, EntityAction.delete) + .then((ExpenseEntity expense) { + store.dispatch(DeleteExpenseSuccess(expense)); + if (action.completer != null) { + action.completer.complete(null); + } + }).catchError((Object error) { + print(error); + store.dispatch(DeleteExpenseFailure(origExpense)); + if (action.completer != null) { + action.completer.completeError(error); + } + }); + + next(action); + }; +} + +Middleware _restoreExpense(ExpenseRepository repository) { + return (Store store, dynamic action, NextDispatcher next) { + final origExpense = store.state.expenseState.map[action.expenseId]; + repository + .saveData(store.state.selectedCompany, store.state.authState, + origExpense, EntityAction.restore) + .then((ExpenseEntity expense) { + store.dispatch(RestoreExpenseSuccess(expense)); + if (action.completer != null) { + action.completer.complete(null); + } + }).catchError((Object error) { + print(error); + store.dispatch(RestoreExpenseFailure(origExpense)); + if (action.completer != null) { + action.completer.completeError(error); + } + }); + + next(action); + }; +} + +Middleware _saveExpense(ExpenseRepository repository) { + return (Store store, dynamic action, NextDispatcher next) { + repository + .saveData( + store.state.selectedCompany, store.state.authState, action.expense) + .then((ExpenseEntity expense) { + if (action.expense.isNew) { + store.dispatch(AddExpenseSuccess(expense)); + } else { + store.dispatch(SaveExpenseSuccess(expense)); + } + action.completer.complete(expense); + }).catchError((Object error) { + print(error); + store.dispatch(SaveExpenseFailure(error)); + action.completer.completeError(error); + }); + + next(action); + }; +} + +Middleware _loadExpense(ExpenseRepository repository) { + return (Store store, dynamic action, NextDispatcher next) { + final AppState state = store.state; + + if (state.isLoading) { + next(action); + return; + } + + store.dispatch(LoadExpenseRequest()); + repository + .loadItem(state.selectedCompany, state.authState, action.expenseId) + .then((expense) { + store.dispatch(LoadExpenseSuccess(expense)); + + if (action.completer != null) { + action.completer.complete(null); + } + }).catchError((Object error) { + print(error); + store.dispatch(LoadExpenseFailure(error)); + if (action.completer != null) { + action.completer.completeError(error); + } + }); + + next(action); + }; +} + +Middleware _loadExpenses(ExpenseRepository repository) { + return (Store store, dynamic action, NextDispatcher next) { + final AppState state = store.state; + + if (!state.expenseState.isStale && !action.force) { + next(action); + return; + } + + if (state.isLoading) { + next(action); + return; + } + + final int updatedAt = (state.expenseState.lastUpdated / 1000).round(); + + store.dispatch(LoadExpensesRequest()); + repository + .loadList(state.selectedCompany, state.authState, updatedAt) + .then((data) { + store.dispatch(LoadExpensesSuccess(data)); + + if (action.completer != null) { + action.completer.complete(null); + } + /* + if (state.productState.isStale) { + store.dispatch(LoadProducts()); + } + */ + }).catchError((Object error) { + print(error); + store.dispatch(LoadExpensesFailure(error)); + if (action.completer != null) { + action.completer.completeError(error); + } + }); + + next(action); + }; +} diff --git a/lib/redux/expense/expense_reducer.dart b/lib/redux/expense/expense_reducer.dart new file mode 100644 index 000000000..8ec9580e2 --- /dev/null +++ b/lib/redux/expense/expense_reducer.dart @@ -0,0 +1,205 @@ +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/expense/expense_actions.dart'; +import 'package:invoiceninja_flutter/redux/expense/expense_state.dart'; + +EntityUIState expenseUIReducer(ExpenseUIState state, dynamic action) { + return state.rebuild((b) => b + ..listUIState.replace(expenseListReducer(state.listUIState, action)) + ..editing.replace(editingReducer(state.editing, action)) + ..selectedId = selectedIdReducer(state.selectedId, action)); +} + +Reducer selectedIdReducer = combineReducers([ + TypedReducer( + (int selectedId, dynamic action) => action.expenseId), + TypedReducer( + (int selectedId, dynamic action) => action.expense.id), +]); + +final editingReducer = combineReducers([ + TypedReducer(_updateEditing), + TypedReducer(_updateEditing), + TypedReducer(_updateEditing), + TypedReducer(_updateEditing), + TypedReducer(_updateEditing), + TypedReducer(_updateEditing), + TypedReducer(_updateEditing), + TypedReducer(_clearEditing), +]); + +ExpenseEntity _clearEditing(ExpenseEntity expense, dynamic action) { + return ExpenseEntity(); +} + +ExpenseEntity _updateEditing(ExpenseEntity expense, dynamic action) { + return action.expense; +} + +final expenseListReducer = combineReducers([ + TypedReducer(_sortExpenses), + TypedReducer(_filterExpensesByState), + TypedReducer(_filterExpenses), + TypedReducer(_filterExpensesByCustom1), + TypedReducer(_filterExpensesByCustom2), + TypedReducer(_filterExpensesByClient), +]); + +ListUIState _filterExpensesByClient( + ListUIState expenseListState, FilterExpensesByEntity action) { + return expenseListState.rebuild((b) => b + ..filterEntityId = action.entityId + ..filterEntityType = action.entityType); +} + +ListUIState _filterExpensesByCustom1( + ListUIState expenseListState, FilterExpensesByCustom1 action) { + if (expenseListState.custom1Filters.contains(action.value)) { + return expenseListState + .rebuild((b) => b..custom1Filters.remove(action.value)); + } else { + return expenseListState.rebuild((b) => b..custom1Filters.add(action.value)); + } +} + +ListUIState _filterExpensesByCustom2( + ListUIState expenseListState, FilterExpensesByCustom2 action) { + if (expenseListState.custom2Filters.contains(action.value)) { + return expenseListState + .rebuild((b) => b..custom2Filters.remove(action.value)); + } else { + return expenseListState.rebuild((b) => b..custom2Filters.add(action.value)); + } +} + +ListUIState _filterExpensesByState( + ListUIState expenseListState, FilterExpensesByState action) { + if (expenseListState.stateFilters.contains(action.state)) { + return expenseListState + .rebuild((b) => b..stateFilters.remove(action.state)); + } else { + return expenseListState.rebuild((b) => b..stateFilters.add(action.state)); + } +} + +ListUIState _filterExpenses( + ListUIState expenseListState, FilterExpenses action) { + return expenseListState.rebuild((b) => b..filter = action.filter); +} + +ListUIState _sortExpenses(ListUIState expenseListState, SortExpenses action) { + return expenseListState.rebuild((b) => b + ..sortAscending = b.sortField != action.field || !b.sortAscending + ..sortField = action.field); +} + +final expensesReducer = combineReducers([ + TypedReducer(_updateExpense), + TypedReducer(_addExpense), + TypedReducer(_setLoadedExpenses), + TypedReducer(_setLoadedExpense), + TypedReducer(_archiveExpenseRequest), + TypedReducer(_archiveExpenseSuccess), + TypedReducer(_archiveExpenseFailure), + TypedReducer(_deleteExpenseRequest), + TypedReducer(_deleteExpenseSuccess), + TypedReducer(_deleteExpenseFailure), + TypedReducer(_restoreExpenseRequest), + TypedReducer(_restoreExpenseSuccess), + TypedReducer(_restoreExpenseFailure), +]); + +ExpenseState _archiveExpenseRequest( + ExpenseState expenseState, ArchiveExpenseRequest action) { + final expense = expenseState.map[action.expenseId] + .rebuild((b) => b..archivedAt = DateTime.now().millisecondsSinceEpoch); + + return expenseState.rebuild((b) => b..map[action.expenseId] = expense); +} + +ExpenseState _archiveExpenseSuccess( + ExpenseState expenseState, ArchiveExpenseSuccess action) { + return expenseState + .rebuild((b) => b..map[action.expense.id] = action.expense); +} + +ExpenseState _archiveExpenseFailure( + ExpenseState expenseState, ArchiveExpenseFailure action) { + return expenseState + .rebuild((b) => b..map[action.expense.id] = action.expense); +} + +ExpenseState _deleteExpenseRequest( + ExpenseState expenseState, DeleteExpenseRequest action) { + final expense = expenseState.map[action.expenseId].rebuild((b) => b + ..archivedAt = DateTime.now().millisecondsSinceEpoch + ..isDeleted = true); + + return expenseState.rebuild((b) => b..map[action.expenseId] = expense); +} + +ExpenseState _deleteExpenseSuccess( + ExpenseState expenseState, DeleteExpenseSuccess action) { + return expenseState + .rebuild((b) => b..map[action.expense.id] = action.expense); +} + +ExpenseState _deleteExpenseFailure( + ExpenseState expenseState, DeleteExpenseFailure action) { + return expenseState + .rebuild((b) => b..map[action.expense.id] = action.expense); +} + +ExpenseState _restoreExpenseRequest( + ExpenseState expenseState, RestoreExpenseRequest action) { + final expense = expenseState.map[action.expenseId].rebuild((b) => b + ..archivedAt = null + ..isDeleted = false); + return expenseState.rebuild((b) => b..map[action.expenseId] = expense); +} + +ExpenseState _restoreExpenseSuccess( + ExpenseState expenseState, RestoreExpenseSuccess action) { + return expenseState + .rebuild((b) => b..map[action.expense.id] = action.expense); +} + +ExpenseState _restoreExpenseFailure( + ExpenseState expenseState, RestoreExpenseFailure action) { + return expenseState + .rebuild((b) => b..map[action.expense.id] = action.expense); +} + +ExpenseState _addExpense(ExpenseState expenseState, AddExpenseSuccess action) { + return expenseState.rebuild((b) => b + ..map[action.expense.id] = action.expense + ..list.add(action.expense.id)); +} + +ExpenseState _updateExpense( + ExpenseState expenseState, SaveExpenseSuccess action) { + return expenseState + .rebuild((b) => b..map[action.expense.id] = action.expense); +} + +ExpenseState _setLoadedExpense( + ExpenseState expenseState, LoadExpenseSuccess action) { + return expenseState + .rebuild((b) => b..map[action.expense.id] = action.expense); +} + +ExpenseState _setLoadedExpenses( + ExpenseState expenseState, LoadExpensesSuccess action) { + final state = expenseState.rebuild((b) => b + ..lastUpdated = DateTime.now().millisecondsSinceEpoch + ..map.addAll(Map.fromIterable( + action.expenses, + key: (dynamic item) => item.id, + value: (dynamic item) => item, + ))); + + return state.rebuild((b) => b..list.replace(state.map.keys)); +} diff --git a/lib/redux/expense/expense_selectors.dart b/lib/redux/expense/expense_selectors.dart new file mode 100644 index 000000000..deec2c65e --- /dev/null +++ b/lib/redux/expense/expense_selectors.dart @@ -0,0 +1,75 @@ +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 memoizedDropdownExpenseList = memo3( + (BuiltMap expenseMap, BuiltList expenseList, + int clientId) => + dropdownExpensesSelector(expenseMap, expenseList, clientId)); + +List dropdownExpensesSelector(BuiltMap expenseMap, + BuiltList expenseList, int clientId) { + final list = expenseList.where((expenseId) { + final expense = expenseMap[expenseId]; + /* + if (clientId != null && clientId > 0 && expense.clientId != clientId) { + return false; + } + */ + return expense.isActive; + }).toList(); + + list.sort((expenseAId, expenseBId) { + final expenseA = expenseMap[expenseAId]; + final expenseB = expenseMap[expenseBId]; + return expenseA.compareTo(expenseB, ExpenseFields.expenseDate, true); + }); + + return list; +} + +var memoizedFilteredExpenseList = memo3( + (BuiltMap expenseMap, BuiltList expenseList, + ListUIState expenseListState) => + filteredExpensesSelector(expenseMap, expenseList, expenseListState)); + +List filteredExpensesSelector(BuiltMap expenseMap, + BuiltList expenseList, ListUIState expenseListState) { + final list = expenseList.where((expenseId) { + final expense = expenseMap[expenseId]; + if (!expense.matchesStates(expenseListState.stateFilters)) { + return false; + } + /* + if (expenseListState.filterEntityId != null && + expense.clientId != expenseListState.filterEntityId) { + return false; + } + */ + if (expenseListState.custom1Filters.isNotEmpty && + !expenseListState.custom1Filters.contains(expense.customValue1)) { + return false; + } + if (expenseListState.custom2Filters.isNotEmpty && + !expenseListState.custom2Filters.contains(expense.customValue2)) { + return false; + } + /* + if (expenseListState.filterEntityId != null && + expense.entityId != expenseListState.filterEntityId) { + return false; + } + */ + return expense.matchesFilter(expenseListState.filter); + }).toList(); + + list.sort((expenseAId, expenseBId) { + final expenseA = expenseMap[expenseAId]; + final expenseB = expenseMap[expenseBId]; + return expenseA.compareTo( + expenseB, expenseListState.sortField, expenseListState.sortAscending); + }); + + return list; +} diff --git a/lib/redux/expense/expense_state.dart b/lib/redux/expense/expense_state.dart new file mode 100644 index 000000000..786100e28 --- /dev/null +++ b/lib/redux/expense/expense_state.dart @@ -0,0 +1,62 @@ +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/expense_model.dart'; +import 'package:invoiceninja_flutter/redux/ui/entity_ui_state.dart'; +import 'package:invoiceninja_flutter/redux/ui/list_ui_state.dart'; + +part 'expense_state.g.dart'; + +abstract class ExpenseState + implements Built { + factory ExpenseState() { + return _$ExpenseState._( + lastUpdated: 0, + map: BuiltMap(), + list: BuiltList(), + ); + } + ExpenseState._(); + + @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 => _$expenseStateSerializer; +} + +abstract class ExpenseUIState extends Object + with EntityUIState + implements Built { + factory ExpenseUIState() { + return _$ExpenseUIState._( + listUIState: ListUIState(ExpenseFields.expenseDate), + editing: ExpenseEntity(), + selectedId: 0, + ); + } + ExpenseUIState._(); + + @nullable + ExpenseEntity get editing; + + @override + bool get isCreatingNew => editing.isNew; + + static Serializer get serializer => + _$expenseUIStateSerializer; +} diff --git a/lib/redux/expense/expense_state.g.dart b/lib/redux/expense/expense_state.g.dart new file mode 100644 index 000000000..349bc6b57 --- /dev/null +++ b/lib/redux/expense/expense_state.g.dart @@ -0,0 +1,392 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'expense_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 _$expenseStateSerializer = + new _$ExpenseStateSerializer(); +Serializer _$expenseUIStateSerializer = + new _$ExpenseUIStateSerializer(); + +class _$ExpenseStateSerializer implements StructuredSerializer { + @override + final Iterable types = const [ExpenseState, _$ExpenseState]; + @override + final String wireName = 'ExpenseState'; + + @override + Iterable serialize(Serializers serializers, ExpenseState object, + {FullType specifiedType = FullType.unspecified}) { + final result = [ + 'map', + serializers.serialize(object.map, + specifiedType: const FullType(BuiltMap, + const [const FullType(int), const FullType(ExpenseEntity)])), + '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 + ExpenseState deserialize(Serializers serializers, Iterable serialized, + {FullType specifiedType = FullType.unspecified}) { + final result = new ExpenseStateBuilder(); + + 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(ExpenseEntity) + ])) 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 _$ExpenseUIStateSerializer + implements StructuredSerializer { + @override + final Iterable types = const [ExpenseUIState, _$ExpenseUIState]; + @override + final String wireName = 'ExpenseUIState'; + + @override + Iterable serialize(Serializers serializers, ExpenseUIState 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(ExpenseEntity))); + } + + return result; + } + + @override + ExpenseUIState deserialize(Serializers serializers, Iterable serialized, + {FullType specifiedType = FullType.unspecified}) { + final result = new ExpenseUIStateBuilder(); + + 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(ExpenseEntity)) as ExpenseEntity); + 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 _$ExpenseState extends ExpenseState { + @override + final int lastUpdated; + @override + final BuiltMap map; + @override + final BuiltList list; + + factory _$ExpenseState([void updates(ExpenseStateBuilder b)]) => + (new ExpenseStateBuilder()..update(updates)).build(); + + _$ExpenseState._({this.lastUpdated, this.map, this.list}) : super._() { + if (map == null) { + throw new BuiltValueNullFieldError('ExpenseState', 'map'); + } + if (list == null) { + throw new BuiltValueNullFieldError('ExpenseState', 'list'); + } + } + + @override + ExpenseState rebuild(void updates(ExpenseStateBuilder b)) => + (toBuilder()..update(updates)).build(); + + @override + ExpenseStateBuilder toBuilder() => new ExpenseStateBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is ExpenseState && + 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('ExpenseState') + ..add('lastUpdated', lastUpdated) + ..add('map', map) + ..add('list', list)) + .toString(); + } +} + +class ExpenseStateBuilder + implements Builder { + _$ExpenseState _$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; + + ExpenseStateBuilder(); + + ExpenseStateBuilder get _$this { + if (_$v != null) { + _lastUpdated = _$v.lastUpdated; + _map = _$v.map?.toBuilder(); + _list = _$v.list?.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(ExpenseState other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$ExpenseState; + } + + @override + void update(void updates(ExpenseStateBuilder b)) { + if (updates != null) updates(this); + } + + @override + _$ExpenseState build() { + _$ExpenseState _$result; + try { + _$result = _$v ?? + new _$ExpenseState._( + 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( + 'ExpenseState', _$failedField, e.toString()); + } + rethrow; + } + replace(_$result); + return _$result; + } +} + +class _$ExpenseUIState extends ExpenseUIState { + @override + final ExpenseEntity editing; + @override + final int selectedId; + @override + final ListUIState listUIState; + + factory _$ExpenseUIState([void updates(ExpenseUIStateBuilder b)]) => + (new ExpenseUIStateBuilder()..update(updates)).build(); + + _$ExpenseUIState._({this.editing, this.selectedId, this.listUIState}) + : super._() { + if (selectedId == null) { + throw new BuiltValueNullFieldError('ExpenseUIState', 'selectedId'); + } + if (listUIState == null) { + throw new BuiltValueNullFieldError('ExpenseUIState', 'listUIState'); + } + } + + @override + ExpenseUIState rebuild(void updates(ExpenseUIStateBuilder b)) => + (toBuilder()..update(updates)).build(); + + @override + ExpenseUIStateBuilder toBuilder() => + new ExpenseUIStateBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is ExpenseUIState && + 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('ExpenseUIState') + ..add('editing', editing) + ..add('selectedId', selectedId) + ..add('listUIState', listUIState)) + .toString(); + } +} + +class ExpenseUIStateBuilder + implements Builder { + _$ExpenseUIState _$v; + + ExpenseEntityBuilder _editing; + ExpenseEntityBuilder get editing => + _$this._editing ??= new ExpenseEntityBuilder(); + set editing(ExpenseEntityBuilder 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; + + ExpenseUIStateBuilder(); + + ExpenseUIStateBuilder get _$this { + if (_$v != null) { + _editing = _$v.editing?.toBuilder(); + _selectedId = _$v.selectedId; + _listUIState = _$v.listUIState?.toBuilder(); + _$v = null; + } + return this; + } + + @override + void replace(ExpenseUIState other) { + if (other == null) { + throw new ArgumentError.notNull('other'); + } + _$v = other as _$ExpenseUIState; + } + + @override + void update(void updates(ExpenseUIStateBuilder b)) { + if (updates != null) updates(this); + } + + @override + _$ExpenseUIState build() { + _$ExpenseUIState _$result; + try { + _$result = _$v ?? + new _$ExpenseUIState._( + 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( + 'ExpenseUIState', _$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 b8b1afb13..d17be3392 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/expense/expense_reducer.dart'; + import 'package:invoiceninja_flutter/redux/vendor/vendor_reducer.dart'; import 'package:invoiceninja_flutter/redux/task/task_reducer.dart'; @@ -36,6 +38,7 @@ UIState uiReducer(UIState state, dynamic action) { ..dashboardUIState .replace(dashboardUIReducer(state.dashboardUIState, action)) // STARTER: reducer - do not remove comment + ..expenseUIState.replace(expenseUIReducer(state.expenseUIState, action)) ..vendorUIState.replace(vendorUIReducer(state.vendorUIState, action)) ..taskUIState.replace(taskUIReducer(state.taskUIState, action)) ..projectUIState.replace(projectUIReducer(state.projectUIState, action)) diff --git a/lib/redux/ui/ui_state.dart b/lib/redux/ui/ui_state.dart index bb65cb226..83ce06e8f 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/expense/expense_state.dart'; + import 'package:invoiceninja_flutter/redux/vendor/vendor_state.dart'; import 'package:invoiceninja_flutter/redux/task/task_state.dart'; @@ -35,6 +37,8 @@ abstract class UIState implements Built { clientUIState: ClientUIState(), invoiceUIState: InvoiceUIState(), // STARTER: constructor - do not remove comment + expenseUIState: ExpenseUIState(), + vendorUIState: VendorUIState(), taskUIState: TaskUIState(), @@ -70,6 +74,8 @@ abstract class UIState implements Built { String get filter; // STARTER: properties - do not remove comment + ExpenseUIState get expenseUIState; + VendorUIState get vendorUIState; TaskUIState get taskUIState; diff --git a/lib/redux/ui/ui_state.g.dart b/lib/redux/ui/ui_state.g.dart index 985f325d7..cf8cc1e5d 100644 --- a/lib/redux/ui/ui_state.g.dart +++ b/lib/redux/ui/ui_state.g.dart @@ -61,6 +61,9 @@ class _$UIStateSerializer implements StructuredSerializer { 'invoiceUIState', serializers.serialize(object.invoiceUIState, specifiedType: const FullType(InvoiceUIState)), + 'expenseUIState', + serializers.serialize(object.expenseUIState, + specifiedType: const FullType(ExpenseUIState)), 'vendorUIState', serializers.serialize(object.vendorUIState, specifiedType: const FullType(VendorUIState)), @@ -143,6 +146,10 @@ class _$UIStateSerializer implements StructuredSerializer { result.filter = serializers.deserialize(value, specifiedType: const FullType(String)) as String; break; + case 'expenseUIState': + result.expenseUIState.replace(serializers.deserialize(value, + specifiedType: const FullType(ExpenseUIState)) as ExpenseUIState); + break; case 'vendorUIState': result.vendorUIState.replace(serializers.deserialize(value, specifiedType: const FullType(VendorUIState)) as VendorUIState); @@ -194,6 +201,8 @@ class _$UIState extends UIState { @override final String filter; @override + final ExpenseUIState expenseUIState; + @override final VendorUIState vendorUIState; @override final TaskUIState taskUIState; @@ -219,6 +228,7 @@ class _$UIState extends UIState { this.clientUIState, this.invoiceUIState, this.filter, + this.expenseUIState, this.vendorUIState, this.taskUIState, this.projectUIState, @@ -255,6 +265,9 @@ class _$UIState extends UIState { if (invoiceUIState == null) { throw new BuiltValueNullFieldError('UIState', 'invoiceUIState'); } + if (expenseUIState == null) { + throw new BuiltValueNullFieldError('UIState', 'expenseUIState'); + } if (vendorUIState == null) { throw new BuiltValueNullFieldError('UIState', 'vendorUIState'); } @@ -294,6 +307,7 @@ class _$UIState extends UIState { clientUIState == other.clientUIState && invoiceUIState == other.invoiceUIState && filter == other.filter && + expenseUIState == other.expenseUIState && vendorUIState == other.vendorUIState && taskUIState == other.taskUIState && projectUIState == other.projectUIState && @@ -319,22 +333,24 @@ class _$UIState extends UIState { $jc( $jc( $jc( - 0, - selectedCompanyIndex + $jc( + 0, + selectedCompanyIndex + .hashCode), + currentRoute .hashCode), - currentRoute + enableDarkMode .hashCode), - enableDarkMode + requireAuthentication .hashCode), - requireAuthentication - .hashCode), - emailPayment.hashCode), - autoStartTasks.hashCode), - dashboardUIState.hashCode), - productUIState.hashCode), - clientUIState.hashCode), - invoiceUIState.hashCode), - filter.hashCode), + emailPayment.hashCode), + autoStartTasks.hashCode), + dashboardUIState.hashCode), + productUIState.hashCode), + clientUIState.hashCode), + invoiceUIState.hashCode), + filter.hashCode), + expenseUIState.hashCode), vendorUIState.hashCode), taskUIState.hashCode), projectUIState.hashCode), @@ -356,6 +372,7 @@ class _$UIState extends UIState { ..add('clientUIState', clientUIState) ..add('invoiceUIState', invoiceUIState) ..add('filter', filter) + ..add('expenseUIState', expenseUIState) ..add('vendorUIState', vendorUIState) ..add('taskUIState', taskUIState) ..add('projectUIState', projectUIState) @@ -424,6 +441,12 @@ class UIStateBuilder implements Builder { String get filter => _$this._filter; set filter(String filter) => _$this._filter = filter; + ExpenseUIStateBuilder _expenseUIState; + ExpenseUIStateBuilder get expenseUIState => + _$this._expenseUIState ??= new ExpenseUIStateBuilder(); + set expenseUIState(ExpenseUIStateBuilder expenseUIState) => + _$this._expenseUIState = expenseUIState; + VendorUIStateBuilder _vendorUIState; VendorUIStateBuilder get vendorUIState => _$this._vendorUIState ??= new VendorUIStateBuilder(); @@ -469,6 +492,7 @@ class UIStateBuilder implements Builder { _clientUIState = _$v.clientUIState?.toBuilder(); _invoiceUIState = _$v.invoiceUIState?.toBuilder(); _filter = _$v.filter; + _expenseUIState = _$v.expenseUIState?.toBuilder(); _vendorUIState = _$v.vendorUIState?.toBuilder(); _taskUIState = _$v.taskUIState?.toBuilder(); _projectUIState = _$v.projectUIState?.toBuilder(); @@ -509,6 +533,7 @@ class UIStateBuilder implements Builder { clientUIState: clientUIState.build(), invoiceUIState: invoiceUIState.build(), filter: filter, + expenseUIState: expenseUIState.build(), vendorUIState: vendorUIState.build(), taskUIState: taskUIState.build(), projectUIState: projectUIState.build(), @@ -526,6 +551,8 @@ class UIStateBuilder implements Builder { _$failedField = 'invoiceUIState'; invoiceUIState.build(); + _$failedField = 'expenseUIState'; + expenseUIState.build(); _$failedField = 'vendorUIState'; vendorUIState.build(); _$failedField = 'taskUIState'; diff --git a/lib/redux/vendor/vendor_reducer.dart b/lib/redux/vendor/vendor_reducer.dart index 727096e9d..4d76cecd4 100644 --- a/lib/redux/vendor/vendor_reducer.dart +++ b/lib/redux/vendor/vendor_reducer.dart @@ -20,7 +20,8 @@ final editingVendorContactReducer = combineReducers([ TypedReducer(editVendorContact), ]); -VendorContactEntity editVendorContact(VendorContactEntity contact, dynamic action) { +VendorContactEntity editVendorContact( + VendorContactEntity contact, dynamic action) { return action.contact ?? VendorContactEntity(); } diff --git a/lib/ui/app/app_drawer.dart b/lib/ui/app/app_drawer.dart index f1d2f48d8..c8090fd18 100644 --- a/lib/ui/app/app_drawer.dart +++ b/lib/ui/app/app_drawer.dart @@ -19,6 +19,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/expense/expense_actions.dart'; + import 'package:invoiceninja_flutter/redux/vendor/vendor_actions.dart'; import 'package:invoiceninja_flutter/redux/task/task_actions.dart'; @@ -253,6 +255,19 @@ class AppDrawer extends StatelessWidget { }, ), // STARTER: menu - do not remove comment + DrawerTile( + company: company, + entityType: EntityType.expense, + icon: getEntityIcon(EntityType.expense), + title: localization.expenses, + onTap: () => store.dispatch(ViewExpenseList(context)), + onCreateTap: () { + navigator.pop(); + store.dispatch( + EditExpense(expense: ExpenseEntity(), context: context)); + }, + ), + DrawerTile( company: company, icon: FontAwesomeIcons.cog, diff --git a/lib/ui/expense/edit/expense_edit.dart b/lib/ui/expense/edit/expense_edit.dart new file mode 100644 index 000000000..026e77905 --- /dev/null +++ b/lib/ui/expense/edit/expense_edit.dart @@ -0,0 +1,110 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:invoiceninja_flutter/ui/app/form_card.dart'; +import 'package:invoiceninja_flutter/ui/expense/edit/expense_edit_vm.dart'; +import 'package:invoiceninja_flutter/ui/app/buttons/action_icon_button.dart'; +import 'package:invoiceninja_flutter/utils/localization.dart'; + +class ExpenseEdit extends StatefulWidget { + const ExpenseEdit({ + Key key, + @required this.viewModel, + }) : super(key: key); + + final ExpenseEditVM viewModel; + + @override + _ExpenseEditState createState() => _ExpenseEditState(); +} + +class _ExpenseEditState 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 expense = widget.viewModel.expense; + // 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 expense = widget.viewModel.expense.rebuild((b) => b + // STARTER: set value - do not remove comment + ); + if (expense != widget.viewModel.expense) { + widget.viewModel.onChanged(expense); + } + } + + @override + Widget build(BuildContext context) { + final viewModel = widget.viewModel; + final localization = AppLocalization.of(context); + final expense = viewModel.expense; + + return WillPopScope( + onWillPop: () async { + viewModel.onBackPressed(); + return true; + }, + child: Scaffold( + appBar: AppBar( + title: Text(viewModel.expense.isNew + ? localization.newExpense + : localization.editExpense), + actions: [ + ActionIconButton( + icon: Icons.cloud_upload, + tooltip: localization.save, + isVisible: !expense.isDeleted, + isDirty: expense.isNew || expense != viewModel.origExpense, + isSaving: viewModel.isSaving, + onPressed: () { + if (!_formKey.currentState.validate()) { + return; + } + viewModel.onSavePressed(context); + }, + ), + ], + ), + body: Form( + key: _formKey, + child: Builder(builder: (BuildContext context) { + return ListView( + children: [ + FormCard( + children: [ + // STARTER: widgets - do not remove comment + ], + ), + ], + ); + })), + ), + ); + } +} diff --git a/lib/ui/expense/edit/expense_edit_vm.dart b/lib/ui/expense/edit/expense_edit_vm.dart new file mode 100644 index 000000000..c04623bde --- /dev/null +++ b/lib/ui/expense/edit/expense_edit_vm.dart @@ -0,0 +1,103 @@ +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/expense/expense_screen.dart'; +import 'package:redux/redux.dart'; +import 'package:invoiceninja_flutter/data/models/models.dart'; +import 'package:invoiceninja_flutter/ui/app/dialogs/error_dialog.dart'; +import 'package:invoiceninja_flutter/ui/expense/view/expense_view_vm.dart'; +import 'package:invoiceninja_flutter/redux/expense/expense_actions.dart'; +import 'package:invoiceninja_flutter/data/models/expense_model.dart'; +import 'package:invoiceninja_flutter/ui/expense/edit/expense_edit.dart'; +import 'package:invoiceninja_flutter/redux/app/app_state.dart'; + +class ExpenseEditScreen extends StatelessWidget { + const ExpenseEditScreen({Key key}) : super(key: key); + static const String route = '/expense/edit'; + + @override + Widget build(BuildContext context) { + return StoreConnector( + converter: (Store store) { + return ExpenseEditVM.fromStore(store); + }, + builder: (context, viewModel) { + return ExpenseEdit( + viewModel: viewModel, + ); + }, + ); + } +} + +class ExpenseEditVM { + ExpenseEditVM({ + @required this.state, + @required this.expense, + @required this.company, + @required this.onChanged, + @required this.isSaving, + @required this.origExpense, + @required this.onSavePressed, + @required this.onBackPressed, + @required this.isLoading, + }); + + factory ExpenseEditVM.fromStore(Store store) { + final expense = store.state.expenseUIState.editing; + final state = store.state; + + return ExpenseEditVM( + state: state, + isLoading: state.isLoading, + isSaving: state.isSaving, + origExpense: state.expenseState.map[expense.id], + expense: expense, + company: state.selectedCompany, + onChanged: (ExpenseEntity expense) { + store.dispatch(UpdateExpense(expense)); + }, + onBackPressed: () { + if (state.uiState.currentRoute.contains(ExpenseScreen.route)) { + store.dispatch(UpdateCurrentRoute( + expense.isNew ? ExpenseScreen.route : ExpenseViewScreen.route)); + } + }, + onSavePressed: (BuildContext context) { + final Completer completer = + new Completer(); + store.dispatch( + SaveExpenseRequest(completer: completer, expense: expense)); + return completer.future.then((_) { + return completer.future.then((savedExpense) { + store.dispatch(UpdateCurrentRoute(ExpenseViewScreen.route)); + if (expense.isNew) { + Navigator.of(context) + .pushReplacementNamed(ExpenseViewScreen.route); + } else { + Navigator.of(context).pop(savedExpense); + } + }).catchError((Object error) { + showDialog( + context: context, + builder: (BuildContext context) { + return ErrorDialog(error); + }); + }); + }); + }, + ); + } + + final ExpenseEntity expense; + final CompanyEntity company; + final Function(ExpenseEntity) onChanged; + final Function(BuildContext) onSavePressed; + final Function onBackPressed; + final bool isLoading; + final bool isSaving; + final ExpenseEntity origExpense; + final AppState state; +} diff --git a/lib/ui/expense/expense_list.dart b/lib/ui/expense/expense_list.dart new file mode 100644 index 000000000..19b3ac389 --- /dev/null +++ b/lib/ui/expense/expense_list.dart @@ -0,0 +1,211 @@ +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/expense/expense_list_item.dart'; +import 'package:invoiceninja_flutter/ui/expense/expense_list_vm.dart'; +import 'package:invoiceninja_flutter/utils/icons.dart'; +import 'package:invoiceninja_flutter/utils/localization.dart'; + +class ExpenseList extends StatelessWidget { + const ExpenseList({ + Key key, + @required this.viewModel, + }) : super(key: key); + + final ExpenseListVM viewModel; + + void _showMenu( + BuildContext context, ExpenseEntity expense, ClientEntity client) async { + if (expense == null || client == null) { + return; + } + + final user = viewModel.user; + final message = await showDialog( + context: context, + builder: (BuildContext dialogContext) => SimpleDialog( + children: expense + .getEntityActions( + user: user, client: client, includeEdit: true) + .map((entityAction) { + if (entityAction == null) { + return Divider(); + } else { + return ListTile( + leading: Icon(getEntityActionIcon(entityAction)), + title: Text(AppLocalization.of(context) + .lookup(entityAction.toString())), + onTap: () { + Navigator.of(dialogContext).pop(); + viewModel.onEntityAction(context, expense, entityAction); + }, + ); + } + }).toList())); + + if (message != null) { + Scaffold.of(context).showSnackBar(SnackBar( + content: SnackBarRow( + message: message, + ))); + } + } + + @override + Widget build(BuildContext context) { + /* + final localization = AppLocalization.of(context); + final listState = viewModel.listState; + final filteredClientId = listState.filterEntityId; + final filteredClient = + filteredClientId != null ? viewModel.clientMap[filteredClientId] : null; + */ + + return Column( + children: [ + Expanded( + child: !viewModel.isLoaded + ? LoadingIndicator() + : RefreshIndicator( + onRefresh: () => viewModel.onRefreshed(context), + child: viewModel.expenseList.isEmpty + ? Opacity( + opacity: 0.5, + child: Center( + child: Text( + AppLocalization.of(context).noRecordsFound, + style: TextStyle( + fontSize: 18.0, + ), + ), + ), + ) + : ListView.builder( + shrinkWrap: true, + itemCount: viewModel.expenseList.length, + itemBuilder: (BuildContext context, index) { + final expenseId = viewModel.expenseList[index]; + final expense = viewModel.expenseMap[expenseId]; + return Column( + children: [ + ExpenseListItem( + user: viewModel.user, + filter: viewModel.filter, + expense: expense, + onTap: () => + viewModel.onExpenseTap(context, expense), + onEntityAction: (EntityAction action) { + if (action == EntityAction.more) { + _showMenu(context, expense, null); + } else { + viewModel.onEntityAction( + context, expense, action); + } + }, + onLongPress: () => + _showMenu(context, expense, null), + ), + Divider( + height: 1.0, + ), + ], + ); + }, + ), + ), + ), + + /* + filteredClient != null + ? Material( + color: Colors.orangeAccent, + elevation: 6.0, + child: InkWell( + onTap: () => viewModel.onViewEntityFilterPressed(context), + child: Row( + children: [ + SizedBox(width: 18.0), + Expanded( + child: Text( + '${localization.filteredBy} ${filteredClient.listDisplayName}', + style: TextStyle( + color: Colors.white, + fontSize: 16.0, + ), + ), + ), + IconButton( + icon: Icon( + Icons.close, + color: Colors.white, + ), + onPressed: () => viewModel.onClearEntityFilterPressed(), + ) + ], + ), + ), + ) + : Container(), + Expanded( + child: !viewModel.isLoaded + ? LoadingIndicator() + : RefreshIndicator( + onRefresh: () => viewModel.onRefreshed(context), + child: viewModel.expenseList.isEmpty + ? Opacity( + opacity: 0.5, + child: Center( + child: Text( + AppLocalization.of(context).noRecordsFound, + style: TextStyle( + fontSize: 18.0, + ), + ), + ), + ) + : ListView.builder( + shrinkWrap: true, + itemCount: viewModel.expenseList.length, + itemBuilder: (BuildContext context, index) { + final expenseId = viewModel.expenseList[index]; + final expense = viewModel.expenseMap[expenseId]; + final client = + viewModel.clientMap[expense.clientId] ?? + ClientEntity(); + return Column( + children: [ + ExpenseListItem( + user: viewModel.user, + filter: viewModel.filter, + expense: expense, + client: + viewModel.clientMap[expense.clientId] ?? + ClientEntity(), + onTap: () => + viewModel.onExpenseTap(context, expense), + onEntityAction: (EntityAction action) { + if (action == EntityAction.more) { + _showMenu(context, expense, client); + } else { + viewModel.onEntityAction( + context, expense, action); + } + }, + onLongPress: () => + _showMenu(context, expense, client), + ), + Divider( + height: 1.0, + ), + ], + ); + }, + ), + ), + ),*/ + ], + ); + } +} diff --git a/lib/ui/expense/expense_list_item.dart b/lib/ui/expense/expense_list_item.dart new file mode 100644 index 000000000..1421b4c1f --- /dev/null +++ b/lib/ui/expense/expense_list_item.dart @@ -0,0 +1,85 @@ +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 ExpenseListItem extends StatelessWidget { + const ExpenseListItem({ + @required this.user, + @required this.onEntityAction, + @required this.onTap, + @required this.onLongPress, + //@required this.onCheckboxChanged, + @required this.expense, + @required this.filter, + }); + + final UserEntity user; + final Function(EntityAction) onEntityAction; + final GestureTapCallback onTap; + final GestureTapCallback onLongPress; + //final ValueChanged onCheckboxChanged; + final ExpenseEntity expense; + final String filter; + + static final expenseItemKey = (int id) => Key('__expense_item_${id}__'); + + @override + Widget build(BuildContext context) { + final filterMatch = filter != null && filter.isNotEmpty + ? expense.matchesFilterValue(filter) + : null; + final subtitle = filterMatch; + + return DismissibleEntity( + user: user, + entity: expense, + onEntityAction: onEntityAction, + child: ListTile( + onTap: onTap, + onLongPress: onLongPress, + /* + leading: Checkbox( + //key: NinjaKeys.expenseItemCheckbox(expense.id), + value: true, + //onChanged: onCheckboxChanged, + onChanged: (value) { + return true; + }, + ), + */ + title: Container( + width: MediaQuery.of(context).size.width, + child: Row( + children: [ + Expanded( + child: Text( + expense.expenseDate, + //key: NinjaKeys.clientItemClientKey(client.id), + style: Theme.of(context).textTheme.title, + ), + ), + Text(formatNumber(expense.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(expense), + ], + ), + ), + ); + } +} diff --git a/lib/ui/expense/expense_list_vm.dart b/lib/ui/expense/expense_list_vm.dart new file mode 100644 index 000000000..eb6a1c76d --- /dev/null +++ b/lib/ui/expense/expense_list_vm.dart @@ -0,0 +1,127 @@ +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/redux/client/client_actions.dart'; +import 'package:invoiceninja_flutter/redux/ui/list_ui_state.dart'; +import 'package:invoiceninja_flutter/utils/completers.dart'; +import 'package:invoiceninja_flutter/utils/localization.dart'; +import 'package:invoiceninja_flutter/redux/expense/expense_selectors.dart'; +import 'package:invoiceninja_flutter/data/models/models.dart'; +import 'package:invoiceninja_flutter/ui/expense/expense_list.dart'; +import 'package:invoiceninja_flutter/redux/app/app_state.dart'; +import 'package:invoiceninja_flutter/redux/expense/expense_actions.dart'; + +class ExpenseListBuilder extends StatelessWidget { + const ExpenseListBuilder({Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return StoreConnector( + converter: ExpenseListVM.fromStore, + builder: (context, viewModel) { + return ExpenseList( + viewModel: viewModel, + ); + }, + ); + } +} + +class ExpenseListVM { + ExpenseListVM({ + @required this.user, + @required this.expenseList, + @required this.expenseMap, + @required this.filter, + @required this.isLoading, + @required this.isLoaded, + @required this.onExpenseTap, + @required this.listState, + @required this.onRefreshed, + @required this.onEntityAction, + @required this.onClearEntityFilterPressed, + @required this.onViewEntityFilterPressed, + }); + + static ExpenseListVM fromStore(Store store) { + Future _handleRefresh(BuildContext context) { + if (store.state.isLoading) { + return Future(null); + } + final completer = snackBarCompleter( + context, AppLocalization.of(context).refreshComplete); + store.dispatch(LoadExpenses(completer: completer, force: true)); + return completer.future; + } + + final state = store.state; + + return ExpenseListVM( + user: state.user, + listState: state.expenseListState, + expenseList: memoizedFilteredExpenseList(state.expenseState.map, + state.expenseState.list, state.expenseListState), + expenseMap: state.expenseState.map, + isLoading: state.isLoading, + isLoaded: state.expenseState.isLoaded, + filter: state.expenseUIState.listUIState.filter, + onClearEntityFilterPressed: () => + store.dispatch(FilterExpensesByEntity()), + onViewEntityFilterPressed: (BuildContext context) => store.dispatch( + ViewClient( + clientId: state.expenseListState.filterEntityId, + context: context)), + onExpenseTap: (context, expense) { + store.dispatch(ViewExpense(expenseId: expense.id, context: context)); + }, + onEntityAction: (context, expense, action) { + switch (action) { + case EntityAction.edit: + store.dispatch(EditExpense(context: context, expense: expense)); + break; + case EntityAction.clone: + Navigator.of(context).pop(); + store.dispatch( + EditExpense(context: context, expense: expense.clone)); + break; + case EntityAction.restore: + store.dispatch(RestoreExpenseRequest( + snackBarCompleter( + context, AppLocalization.of(context).restoredExpense), + expense.id)); + break; + case EntityAction.archive: + store.dispatch(ArchiveExpenseRequest( + snackBarCompleter( + context, AppLocalization.of(context).archivedExpense), + expense.id)); + break; + case EntityAction.delete: + store.dispatch(DeleteExpenseRequest( + snackBarCompleter( + context, AppLocalization.of(context).deletedExpense), + expense.id)); + break; + } + }, + onRefreshed: (context) => _handleRefresh(context), + ); + } + + final UserEntity user; + final List expenseList; + final BuiltMap expenseMap; + final ListUIState listState; + final String filter; + final bool isLoading; + final bool isLoaded; + final Function(BuildContext, ExpenseEntity) onExpenseTap; + final Function(BuildContext) onRefreshed; + final Function(BuildContext, ExpenseEntity, EntityAction) onEntityAction; + final Function onClearEntityFilterPressed; + final Function(BuildContext) onViewEntityFilterPressed; +} diff --git a/lib/ui/expense/expense_screen.dart b/lib/ui/expense/expense_screen.dart new file mode 100644 index 000000000..ae3582a4f --- /dev/null +++ b/lib/ui/expense/expense_screen.dart @@ -0,0 +1,85 @@ +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/expense/expense_list_vm.dart'; +import 'package:invoiceninja_flutter/redux/expense/expense_actions.dart'; +import 'package:invoiceninja_flutter/ui/app/app_drawer_vm.dart'; +import 'package:invoiceninja_flutter/ui/app/app_bottom_bar.dart'; + +class ExpenseScreen extends StatelessWidget { + static const String route = '/expense'; + + @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.expense, + onFilterChanged: (value) { + store.dispatch(FilterExpenses(value)); + }, + ), + actions: [ + ListFilterButton( + entityType: EntityType.expense, + onFilterPressed: (String value) { + store.dispatch(FilterExpenses(value)); + }, + ), + ], + ), + drawer: AppDrawerBuilder(), + body: ExpenseListBuilder(), + bottomNavigationBar: AppBottomBar( + entityType: EntityType.expense, + onSelectedSortField: (value) => store.dispatch(SortExpenses(value)), + customValues1: company.getCustomFieldValues(CustomFieldType.expense1, + excludeBlank: true), + customValues2: company.getCustomFieldValues(CustomFieldType.expense2, + excludeBlank: true), + onSelectedCustom1: (value) => + store.dispatch(FilterExpensesByCustom1(value)), + onSelectedCustom2: (value) => + store.dispatch(FilterExpensesByCustom2(value)), + sortFields: [ + ExpenseFields.updatedAt, + ], + onSelectedState: (EntityState state, value) { + store.dispatch(FilterExpensesByState(state)); + }, + ), + floatingActionButtonLocation: FloatingActionButtonLocation.endDocked, + floatingActionButton: user.canCreate(EntityType.expense) + ? FloatingActionButton( + //key: Key(ExpenseKeys.expenseScreenFABKeyString), + backgroundColor: Theme.of(context).primaryColorDark, + onPressed: () { + store.dispatch( + EditExpense(expense: ExpenseEntity(), context: context)); + }, + child: Icon( + Icons.add, + color: Colors.white, + ), + tooltip: localization.newExpense, + ) + : null, + ), + ); + } +} diff --git a/lib/ui/expense/view/expense_view.dart b/lib/ui/expense/view/expense_view.dart new file mode 100644 index 000000000..30b513b92 --- /dev/null +++ b/lib/ui/expense/view/expense_view.dart @@ -0,0 +1,50 @@ +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/expense/view/expense_view_vm.dart'; +import 'package:invoiceninja_flutter/ui/app/form_card.dart'; + +class ExpenseView extends StatefulWidget { + const ExpenseView({ + Key key, + @required this.viewModel, + }) : super(key: key); + + final ExpenseViewVM viewModel; + + @override + _ExpenseViewState createState() => new _ExpenseViewState(); +} + +class _ExpenseViewState extends State { + @override + Widget build(BuildContext context) { + final viewModel = widget.viewModel; + final expense = viewModel.expense; + + return Scaffold( + appBar: AppBar( + title: Text(expense.expenseDate), + actions: expense.isNew + ? [] + : [ + IconButton( + icon: Icon(Icons.edit), + onPressed: () { + viewModel.onEditPressed(context); + }, + ), + ActionMenuButton( + user: viewModel.company.user, + isSaving: viewModel.isSaving, + entity: expense, + onSelected: viewModel.onActionSelected, + ), + ], + ), + body: FormCard(children: [ + // STARTER: widgets - do not remove comment + ]), + ); + } +} diff --git a/lib/ui/expense/view/expense_view_vm.dart b/lib/ui/expense/view/expense_view_vm.dart new file mode 100644 index 000000000..aade90277 --- /dev/null +++ b/lib/ui/expense/view/expense_view_vm.dart @@ -0,0 +1,117 @@ +import 'dart:async'; +import 'package:invoiceninja_flutter/ui/app/snackbar_row.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:invoiceninja_flutter/redux/ui/ui_actions.dart'; +import 'package:invoiceninja_flutter/utils/completers.dart'; +import 'package:invoiceninja_flutter/ui/expense/expense_screen.dart'; +import 'package:invoiceninja_flutter/utils/localization.dart'; +import 'package:redux/redux.dart'; +import 'package:flutter_redux/flutter_redux.dart'; +import 'package:invoiceninja_flutter/redux/expense/expense_actions.dart'; +import 'package:invoiceninja_flutter/data/models/expense_model.dart'; +import 'package:invoiceninja_flutter/data/models/models.dart'; +import 'package:invoiceninja_flutter/ui/expense/view/expense_view.dart'; +import 'package:invoiceninja_flutter/redux/app/app_state.dart'; + +class ExpenseViewScreen extends StatelessWidget { + const ExpenseViewScreen({Key key}) : super(key: key); + static const String route = '/expense/view'; + + @override + Widget build(BuildContext context) { + return StoreConnector( + converter: (Store store) { + return ExpenseViewVM.fromStore(store); + }, + builder: (context, vm) { + return ExpenseView( + viewModel: vm, + ); + }, + ); + } +} + +class ExpenseViewVM { + ExpenseViewVM({ + @required this.state, + @required this.expense, + @required this.company, + @required this.onActionSelected, + @required this.onEditPressed, + @required this.onBackPressed, + @required this.onRefreshed, + @required this.isSaving, + @required this.isLoading, + @required this.isDirty, + }); + + factory ExpenseViewVM.fromStore(Store store) { + final state = store.state; + final expense = state.expenseState.map[state.expenseUIState.selectedId]; + + Future _handleRefresh(BuildContext context) { + final completer = snackBarCompleter( + context, AppLocalization.of(context).refreshComplete); + store.dispatch(LoadExpense(completer: completer, expenseId: expense.id)); + return completer.future; + } + + return ExpenseViewVM( + state: state, + company: state.selectedCompany, + isSaving: state.isSaving, + isLoading: state.isLoading, + isDirty: expense.isNew, + expense: expense, + onEditPressed: (BuildContext context) { + final Completer completer = Completer(); + store.dispatch(EditExpense( + expense: expense, context: context, completer: completer)); + completer.future.then((expense) { + Scaffold.of(context).showSnackBar(SnackBar( + content: SnackBarRow( + message: AppLocalization.of(context).updatedExpense, + ))); + }); + }, + onRefreshed: (context) => _handleRefresh(context), + onBackPressed: () { + if (state.uiState.currentRoute.contains(ExpenseScreen.route)) { + store.dispatch(UpdateCurrentRoute(ExpenseScreen.route)); + } + }, + onActionSelected: (BuildContext context, EntityAction action) { + final localization = AppLocalization.of(context); + switch (action) { + case EntityAction.archive: + store.dispatch(ArchiveExpenseRequest( + popCompleter(context, localization.archivedExpense), + expense.id)); + break; + case EntityAction.delete: + store.dispatch(DeleteExpenseRequest( + popCompleter(context, localization.deletedExpense), + expense.id)); + break; + case EntityAction.restore: + store.dispatch(RestoreExpenseRequest( + snackBarCompleter(context, localization.restoredExpense), + expense.id)); + break; + } + }); + } + + final AppState state; + final ExpenseEntity expense; + final CompanyEntity company; + final Function(BuildContext, EntityAction) onActionSelected; + final Function(BuildContext) onEditPressed; + final Function onBackPressed; + final Function(BuildContext) onRefreshed; + final bool isSaving; + final bool isLoading; + final bool isDirty; +} diff --git a/lib/ui/vendor/edit/vendor_edit_address.dart b/lib/ui/vendor/edit/vendor_edit_address.dart index 9826b0c61..138594249 100644 --- a/lib/ui/vendor/edit/vendor_edit_address.dart +++ b/lib/ui/vendor/edit/vendor_edit_address.dart @@ -16,8 +16,7 @@ class VendorEditAddress extends StatefulWidget { final VendorEditVM viewModel; @override - VendorEditAddressState createState() => - VendorEditAddressState(); + VendorEditAddressState createState() => VendorEditAddressState(); } class VendorEditAddressState extends State { @@ -126,7 +125,8 @@ class VendorEditAddressState extends State { key: ValueKey(vendor.countryId), entityType: EntityType.country, entityMap: viewModel.state.staticState.countryMap, - entityList: memoizedCountryList(viewModel.state.staticState.countryMap), + entityList: + memoizedCountryList(viewModel.state.staticState.countryMap), labelText: localization.country, initialValue: viewModel.state.staticState.countryMap[vendor.countryId]?.name, diff --git a/lib/ui/vendor/edit/vendor_edit_contacts_vm.dart b/lib/ui/vendor/edit/vendor_edit_contacts_vm.dart index 740f89b01..a562f7b82 100644 --- a/lib/ui/vendor/edit/vendor_edit_contacts_vm.dart +++ b/lib/ui/vendor/edit/vendor_edit_contacts_vm.dart @@ -49,7 +49,8 @@ class VendorEditContactsVM { store.dispatch(AddVendorContact(contact)); store.dispatch(EditVendorContact(contact)); }, - onRemoveContactPressed: (index) => store.dispatch(DeleteVendorContact(index)), + onRemoveContactPressed: (index) => + store.dispatch(DeleteVendorContact(index)), onDoneContactPressed: () => store.dispatch(EditVendorContact()), onChangedContact: (contact, index) { store.dispatch(UpdateVendorContact(contact: contact, index: index)); diff --git a/lib/ui/vendor/edit/vendor_edit_notes.dart b/lib/ui/vendor/edit/vendor_edit_notes.dart index 755b38e7e..0d2449d72 100644 --- a/lib/ui/vendor/edit/vendor_edit_notes.dart +++ b/lib/ui/vendor/edit/vendor_edit_notes.dart @@ -81,7 +81,8 @@ class VendorEditNotesState extends State { entityMap: staticState.currencyMap, entityList: memoizedCurrencyList(staticState.currencyMap), labelText: localization.currency, - initialValue: staticState.currencyMap[viewModel.vendor.currencyId]?.name, + initialValue: + staticState.currencyMap[viewModel.vendor.currencyId]?.name, onSelected: (SelectableEntity currency) => viewModel.onChanged( viewModel.vendor.rebuild((b) => b..currencyId = currency.id)), ), diff --git a/stubs/ui/stub/stub_list b/stubs/ui/stub/stub_list index c966d1f11..90d951dbc 100644 --- a/stubs/ui/stub/stub_list +++ b/stubs/ui/stub/stub_list @@ -90,12 +90,12 @@ class StubList extends StatelessWidget { final stub = viewModel.stubMap[stubId]; return Column( children: [ - VendorListItem( + StubListItem( user: viewModel.user, filter: viewModel.filter, stub: stub, onTap: () => - viewModel.onVendorTap(context, stub), + viewModel.onStubTap(context, stub), onEntityAction: (EntityAction action) { if (action == EntityAction.more) { _showMenu(context, stub, null);