diff --git a/.gitignore b/.gitignore index f33f7f5b5..4a7cc9c56 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ key.properties google-services.json GoogleService-Info.plist ios/Runner/Info.plist -android/app/build.gradle \ No newline at end of file +android/app/build.gradle +android/app/release/ \ No newline at end of file diff --git a/lib/constants.dart b/lib/constants.dart index 71ac4b61a..af6b1c0b4 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -20,6 +20,9 @@ const String kSharedPrefAppVersion = 'app_version'; const String kSharedPrefRequireAuthentication = 'require_authentication'; const String kSharedPrefAddDocumentsToInvoice = 'add_documents_to_invoice'; +const int kMobileLayoutWidth = 600; +const int kTabletLayoutWidth = 1000; + const String kPlanFree = ''; const String kPlanPro = 'pro'; const String kPlanEnterprise = 'enterprise'; diff --git a/lib/data/models/serializers.g.dart b/lib/data/models/serializers.g.dart index 565f89621..a454ac478 100644 --- a/lib/data/models/serializers.g.dart +++ b/lib/data/models/serializers.g.dart @@ -8,6 +8,7 @@ part of 'serializers.dart'; Serializers _$serializers = (new Serializers().toBuilder() ..add(ActivityEntity.serializer) + ..add(AppLayout.serializer) ..add(AppState.serializer) ..add(AuthState.serializer) ..add(ClientEntity.serializer) diff --git a/lib/main.dart b/lib/main.dart index 593e7a6ef..51bb62f06 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:intl/intl.dart'; import 'package:invoiceninja_flutter/.env.dart'; +import 'package:invoiceninja_flutter/ui/app/main_screen.dart'; import 'package:invoiceninja_flutter/ui/product/view/product_view_vm.dart'; import 'package:sentry/sentry.dart'; import 'package:invoiceninja_flutter/redux/company/company_selectors.dart'; @@ -95,8 +96,9 @@ void main() async { final store = Store(appReducer, initialState: AppState( - enableDarkMode: enableDarkMode, - requireAuthentication: requireAuthentication), + enableDarkMode: enableDarkMode, + requireAuthentication: requireAuthentication, + ), middleware: [] ..addAll(createStoreAuthMiddleware()) ..addAll(createStoreDocumentsMiddleware()) @@ -221,7 +223,6 @@ class InvoiceNinjaAppState extends State { final state = widget.store.state; Intl.defaultLocale = localeSelector(state); final localization = AppLocalization(Locale(Intl.defaultLocale)); - return MaterialApp( supportedLocales: kLanguages .map((String locale) => AppLocalization.createLocale(locale)) @@ -286,6 +287,9 @@ class InvoiceNinjaAppState extends State { LoginScreen.route: (context) { return LoginScreen(); }, + MainScreen.route: (context) { + return MainScreen(); + }, DashboardScreen.route: (context) { if (widget.store.state.dashboardState.isStale) { widget.store.dispatch(LoadDashboard()); @@ -348,7 +352,6 @@ class InvoiceNinjaAppState extends State { }, 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_actions.dart b/lib/redux/app/app_actions.dart index f29566158..5da654478 100644 --- a/lib/redux/app/app_actions.dart +++ b/lib/redux/app/app_actions.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:flutter/material.dart'; import 'package:invoiceninja_flutter/data/models/entities.dart'; +import 'package:invoiceninja_flutter/redux/ui/ui_state.dart'; class PersistUI {} @@ -13,6 +15,18 @@ class RefreshClient { final int clientId; } + +class UpdateLayout { + + UpdateLayout(this.layout); + final AppLayout layout; +} + +class ViewMainScreen { + ViewMainScreen(this.context); + BuildContext context; +} + class StartLoading {} class StopLoading {} diff --git a/lib/redux/app/app_middleware.dart b/lib/redux/app/app_middleware.dart index f8b030053..d30ecbb76 100644 --- a/lib/redux/app/app_middleware.dart +++ b/lib/redux/app/app_middleware.dart @@ -15,6 +15,7 @@ import 'package:invoiceninja_flutter/redux/dashboard/dashboard_actions.dart'; import 'package:invoiceninja_flutter/redux/static/static_state.dart'; import 'package:invoiceninja_flutter/redux/ui/ui_state.dart'; import 'package:invoiceninja_flutter/ui/app/app_builder.dart'; +import 'package:invoiceninja_flutter/ui/app/main_screen.dart'; import 'package:invoiceninja_flutter/ui/auth/login_vm.dart'; import 'package:invoiceninja_flutter/utils/platforms.dart'; import 'package:redux/redux.dart'; @@ -110,6 +111,8 @@ List> createStorePersistenceMiddleware([ company4Repository, company5Repository); + final viewMainScreen = _createViewMainScreen(); + return [ TypedMiddleware(deleteState), TypedMiddleware(loadState), @@ -118,6 +121,7 @@ List> createStorePersistenceMiddleware([ TypedMiddleware(persistData), TypedMiddleware(persistStatic), TypedMiddleware(persistUI), + TypedMiddleware(viewMainScreen), ]; } @@ -182,15 +186,21 @@ Middleware _createLoadState( if (uiState.currentRoute != LoginScreen.route && authState.url.isNotEmpty) { final NavigatorState navigator = Navigator.of(action.context); - bool isFirst = true; - _getRoutes(appState).forEach((route) { - if (isFirst) { - navigator.pushReplacementNamed(route); - } else { - navigator.pushNamed(route); - } - isFirst = false; - }); + if (uiState.layout == AppLayout.mobile) { + bool isFirst = true; + _getRoutes(appState).forEach((route) { + if (isFirst) { + navigator.pushReplacementNamed(route); + } else { + navigator.pushNamed(route); + } + isFirst = false; + }); + } else { + store.dispatch(ViewMainScreen(action.context)); + } + } else { + throw 'Unknown page'; } } catch (error) { print(error); @@ -238,7 +248,8 @@ List _getRoutes(AppState state) { route += '/view'; } } else { - if (!['dashboard', 'settings'].contains(part) && entityType == null) { + if (!['main', 'dashboard', 'settings'].contains(part) && + entityType == null) { entityType = EntityType.valueOf(part); } @@ -384,6 +395,15 @@ Middleware _createDeleteState( }; } +Middleware _createViewMainScreen() { + return (Store store, dynamic action, NextDispatcher next) { + Navigator.of(action.context).pushNamedAndRemoveUntil( + MainScreen.route, (Route route) => false); + + next(action); + }; +} + /* Future _checkLastLoadWasSuccesfull() async { final SharedPreferences prefs = await SharedPreferences.getInstance(); diff --git a/lib/redux/app/app_state.dart b/lib/redux/app/app_state.dart index 0f57f5891..e9f48dad4 100644 --- a/lib/redux/app/app_state.dart +++ b/lib/redux/app/app_state.dart @@ -25,8 +25,12 @@ import 'package:invoiceninja_flutter/redux/quote/quote_state.dart'; part 'app_state.g.dart'; abstract class AppState implements Built { - factory AppState( - {String appVersion, bool enableDarkMode, bool requireAuthentication}) { + factory AppState({ + String appVersion, + bool enableDarkMode, + bool requireAuthentication, + AppLayout layout, + }) { return _$AppState._( isLoading: false, isSaving: false, @@ -38,9 +42,12 @@ abstract class AppState implements Built { companyState3: CompanyState(), companyState4: CompanyState(), companyState5: CompanyState(), - uiState: UIState(CompanyEntity(), - enableDarkMode: enableDarkMode, - requireAuthentication: requireAuthentication), + uiState: UIState( + CompanyEntity(), + enableDarkMode: enableDarkMode, + requireAuthentication: requireAuthentication, + layout: layout ?? AppLayout.mobile, + ), ); } @@ -156,7 +163,9 @@ abstract class AppState implements Built { // STARTER: state getters - do not remove comment DocumentState get documentState => selectedCompanyState.documentState; + ListUIState get documentListState => uiState.documentUIState.listUIState; + DocumentUIState get documentUIState => uiState.documentUIState; ExpenseState get expenseState => selectedCompanyState.expenseState; @@ -219,6 +228,6 @@ abstract class AppState implements Built { String toString() { //return 'Is Loading: ${this.isLoading}, Invoice: ${this.invoiceUIState.selected}'; //return 'Expense Categories: ${selectedCompany.expenseCategories}'; - return 'Route: ${uiState.currentRoute}: Server Version: $serverVersion'; + return 'Layout: ${uiState.layout}, Route: ${uiState.currentRoute}'; } } diff --git a/lib/redux/client/client_middleware.dart b/lib/redux/client/client_middleware.dart index 16ca08c82..d5ecc0b64 100644 --- a/lib/redux/client/client_middleware.dart +++ b/lib/redux/client/client_middleware.dart @@ -6,6 +6,7 @@ import 'package:invoiceninja_flutter/redux/ui/ui_actions.dart'; import 'package:invoiceninja_flutter/ui/client/client_screen.dart'; import 'package:invoiceninja_flutter/ui/client/edit/client_edit_vm.dart'; import 'package:invoiceninja_flutter/ui/client/view/client_view_vm.dart'; +import 'package:invoiceninja_flutter/utils/platforms.dart'; import 'package:redux/redux.dart'; import 'package:invoiceninja_flutter/redux/client/client_actions.dart'; import 'package:invoiceninja_flutter/redux/app/app_state.dart'; @@ -45,11 +46,13 @@ Middleware _editClient() { store.dispatch(UpdateCurrentRoute(ClientEditScreen.route)); } - final client = - await Navigator.of(action.context).pushNamed(ClientEditScreen.route); + if (action.context != null && isMobile(action.context)) { + final client = + await Navigator.of(action.context).pushNamed(ClientEditScreen.route); - if (action.completer != null && client != null) { - action.completer.complete(client); + if (action.completer != null && client != null) { + action.completer.complete(client); + } } }; } diff --git a/lib/redux/dashboard/dashboard_middleware.dart b/lib/redux/dashboard/dashboard_middleware.dart index 8c64d0b90..1af20f191 100644 --- a/lib/redux/dashboard/dashboard_middleware.dart +++ b/lib/redux/dashboard/dashboard_middleware.dart @@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart'; import 'package:invoiceninja_flutter/redux/client/client_actions.dart'; import 'package:invoiceninja_flutter/redux/ui/ui_actions.dart'; import 'package:invoiceninja_flutter/ui/dashboard/dashboard_screen.dart'; +import 'package:invoiceninja_flutter/utils/platforms.dart'; import 'package:redux/redux.dart'; import 'package:invoiceninja_flutter/redux/dashboard/dashboard_actions.dart'; import 'package:invoiceninja_flutter/redux/app/app_state.dart'; @@ -24,7 +25,7 @@ Middleware _createViewDashboard() { store.dispatch(LoadDashboard()); store.dispatch(UpdateCurrentRoute(DashboardScreen.route)); - if (action.context != null) { + if (action.context != null && isMobile(action.context)) { Navigator.of(action.context).pushNamedAndRemoveUntil( DashboardScreen.route, (Route route) => false); } diff --git a/lib/redux/product/product_middleware.dart b/lib/redux/product/product_middleware.dart index 22cf22801..19df56537 100644 --- a/lib/redux/product/product_middleware.dart +++ b/lib/redux/product/product_middleware.dart @@ -5,6 +5,7 @@ import 'package:invoiceninja_flutter/redux/invoice/invoice_actions.dart'; import 'package:invoiceninja_flutter/ui/product/edit/product_edit_vm.dart'; import 'package:invoiceninja_flutter/ui/product/product_screen.dart'; import 'package:invoiceninja_flutter/ui/product/view/product_view_vm.dart'; +import 'package:invoiceninja_flutter/utils/platforms.dart'; import 'package:redux/redux.dart'; import 'package:invoiceninja_flutter/redux/product/product_actions.dart'; import 'package:invoiceninja_flutter/redux/app/app_state.dart'; @@ -48,7 +49,10 @@ Middleware _viewProduct() { next(action); store.dispatch(UpdateCurrentRoute(ProductViewScreen.route)); - Navigator.of(action.context).pushNamed(ProductViewScreen.route); + + if (action.context != null && isMobile(action.context)) { + Navigator.of(action.context).pushNamed(ProductViewScreen.route); + } }; } diff --git a/lib/redux/ui/ui_reducer.dart b/lib/redux/ui/ui_reducer.dart index c22f261a5..411b4af64 100644 --- a/lib/redux/ui/ui_reducer.dart +++ b/lib/redux/ui/ui_reducer.dart @@ -28,6 +28,7 @@ UIState uiReducer(UIState state, dynamic action) { ..filter = filterReducer(state.filter, action) ..selectedCompanyIndex = selectedCompanyIndexReducer(state.selectedCompanyIndex, action) + ..layout = layoutReducer(state.layout, action) ..currentRoute = currentRouteReducer(state.currentRoute, action) ..enableDarkMode = darkModeReducer(state.enableDarkMode, action) ..autoStartTasks = autoStartTasksReducer(state.autoStartTasks, action) @@ -59,6 +60,14 @@ String updateFilter(String filter, FilterCompany action) { return action.filter; } +Reducer layoutReducer = combineReducers([ + TypedReducer(updateLayout), +]); + +AppLayout updateLayout(AppLayout layout, UpdateLayout action) { + return action.layout; +} + Reducer emailPaymentReducer = combineReducers([ TypedReducer(updateEmailPaymentReducer), ]); diff --git a/lib/redux/ui/ui_state.dart b/lib/redux/ui/ui_state.dart index 9bb31bcb2..c009583e7 100644 --- a/lib/redux/ui/ui_state.dart +++ b/lib/redux/ui/ui_state.dart @@ -1,3 +1,4 @@ +import 'package:built_collection/built_collection.dart'; import 'package:built_value/built_value.dart'; import 'package:built_value/serializer.dart'; import 'package:invoiceninja_flutter/data/models/company_model.dart'; @@ -26,9 +27,10 @@ part 'ui_state.g.dart'; abstract class UIState implements Built { factory UIState(CompanyEntity company, - {bool enableDarkMode, bool requireAuthentication}) { + {bool enableDarkMode, bool requireAuthentication, AppLayout layout}) { return _$UIState._( selectedCompanyIndex: 0, + layout: layout ?? AppLayout.mobile, currentRoute: LoginScreen.route, enableDarkMode: enableDarkMode ?? false, requireAuthentication: requireAuthentication ?? false, @@ -41,7 +43,6 @@ abstract class UIState implements Built { invoiceUIState: InvoiceUIState(), // STARTER: constructor - do not remove comment documentUIState: DocumentUIState(), - expenseUIState: ExpenseUIState(), vendorUIState: VendorUIState(), taskUIState: TaskUIState(), @@ -53,6 +54,8 @@ abstract class UIState implements Built { UIState._(); + AppLayout get layout; + int get selectedCompanyIndex; String get currentRoute; @@ -97,3 +100,17 @@ abstract class UIState implements Built { bool containsRoute(String route) => currentRoute.contains(route); } + +class AppLayout extends EnumClass { + const AppLayout._(String name) : super(name); + + static Serializer get serializer => _$appLayoutSerializer; + + static const AppLayout mobile = _$mobile; + static const AppLayout tablet = _$tablet; + static const AppLayout desktop = _$desktop; + + static BuiltSet get values => _$values; + + static AppLayout valueOf(String name) => _$valueOf(name); +} diff --git a/lib/redux/ui/ui_state.g.dart b/lib/redux/ui/ui_state.g.dart index 01d8bfa3a..991ef0829 100644 --- a/lib/redux/ui/ui_state.g.dart +++ b/lib/redux/ui/ui_state.g.dart @@ -6,7 +6,31 @@ part of 'ui_state.dart'; // BuiltValueGenerator // ************************************************************************** +const AppLayout _$mobile = const AppLayout._('mobile'); +const AppLayout _$tablet = const AppLayout._('tablet'); +const AppLayout _$desktop = const AppLayout._('desktop'); + +AppLayout _$valueOf(String name) { + switch (name) { + case 'mobile': + return _$mobile; + case 'tablet': + return _$tablet; + case 'desktop': + return _$desktop; + default: + throw new ArgumentError(name); + } +} + +final BuiltSet _$values = new BuiltSet(const [ + _$mobile, + _$tablet, + _$desktop, +]); + Serializer _$uIStateSerializer = new _$UIStateSerializer(); +Serializer _$appLayoutSerializer = new _$AppLayoutSerializer(); class _$UIStateSerializer implements StructuredSerializer { @override @@ -18,6 +42,9 @@ class _$UIStateSerializer implements StructuredSerializer { Iterable serialize(Serializers serializers, UIState object, {FullType specifiedType = FullType.unspecified}) { final result = [ + 'layout', + serializers.serialize(object.layout, + specifiedType: const FullType(AppLayout)), 'selectedCompanyIndex', serializers.serialize(object.selectedCompanyIndex, specifiedType: const FullType(int)), @@ -93,6 +120,10 @@ class _$UIStateSerializer implements StructuredSerializer { iterator.moveNext(); final dynamic value = iterator.current; switch (key) { + case 'layout': + result.layout = serializers.deserialize(value, + specifiedType: const FullType(AppLayout)) as AppLayout; + break; case 'selectedCompanyIndex': result.selectedCompanyIndex = serializers.deserialize(value, specifiedType: const FullType(int)) as int; @@ -178,7 +209,26 @@ class _$UIStateSerializer implements StructuredSerializer { } } +class _$AppLayoutSerializer implements PrimitiveSerializer { + @override + final Iterable types = const [AppLayout]; + @override + final String wireName = 'AppLayout'; + + @override + Object serialize(Serializers serializers, AppLayout object, + {FullType specifiedType = FullType.unspecified}) => + object.name; + + @override + AppLayout deserialize(Serializers serializers, Object serialized, + {FullType specifiedType = FullType.unspecified}) => + AppLayout.valueOf(serialized as String); +} + class _$UIState extends UIState { + @override + final AppLayout layout; @override final int selectedCompanyIndex; @override @@ -222,7 +272,8 @@ class _$UIState extends UIState { (new UIStateBuilder()..update(updates)).build(); _$UIState._( - {this.selectedCompanyIndex, + {this.layout, + this.selectedCompanyIndex, this.currentRoute, this.enableDarkMode, this.requireAuthentication, @@ -242,6 +293,9 @@ class _$UIState extends UIState { this.paymentUIState, this.quoteUIState}) : super._() { + if (layout == null) { + throw new BuiltValueNullFieldError('UIState', 'layout'); + } if (selectedCompanyIndex == null) { throw new BuiltValueNullFieldError('UIState', 'selectedCompanyIndex'); } @@ -309,6 +363,7 @@ class _$UIState extends UIState { bool operator ==(Object other) { if (identical(other, this)) return true; return other is UIState && + layout == other.layout && selectedCompanyIndex == other.selectedCompanyIndex && currentRoute == other.currentRoute && enableDarkMode == other.enableDarkMode && @@ -351,7 +406,10 @@ class _$UIState extends UIState { $jc( $jc( $jc( - 0, + $jc( + 0, + layout + .hashCode), selectedCompanyIndex .hashCode), currentRoute @@ -383,6 +441,7 @@ class _$UIState extends UIState { @override String toString() { return (newBuiltValueToStringHelper('UIState') + ..add('layout', layout) ..add('selectedCompanyIndex', selectedCompanyIndex) ..add('currentRoute', currentRoute) ..add('enableDarkMode', enableDarkMode) @@ -409,6 +468,10 @@ class _$UIState extends UIState { class UIStateBuilder implements Builder { _$UIState _$v; + AppLayout _layout; + AppLayout get layout => _$this._layout; + set layout(AppLayout layout) => _$this._layout = layout; + int _selectedCompanyIndex; int get selectedCompanyIndex => _$this._selectedCompanyIndex; set selectedCompanyIndex(int selectedCompanyIndex) => @@ -516,6 +579,7 @@ class UIStateBuilder implements Builder { UIStateBuilder get _$this { if (_$v != null) { + _layout = _$v.layout; _selectedCompanyIndex = _$v.selectedCompanyIndex; _currentRoute = _$v.currentRoute; _enableDarkMode = _$v.enableDarkMode; @@ -559,6 +623,7 @@ class UIStateBuilder implements Builder { try { _$result = _$v ?? new _$UIState._( + layout: layout, selectedCompanyIndex: selectedCompanyIndex, currentRoute: currentRoute, enableDarkMode: enableDarkMode, diff --git a/lib/ui/app/main_screen.dart b/lib/ui/app/main_screen.dart new file mode 100644 index 000000000..3ef0d65f1 --- /dev/null +++ b/lib/ui/app/main_screen.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:invoiceninja_flutter/ui/app/app_drawer_vm.dart'; +import 'package:invoiceninja_flutter/ui/invoice/edit/invoice_edit_vm.dart'; +import 'package:invoiceninja_flutter/ui/invoice/invoice_screen.dart'; + +class MainScreen extends StatelessWidget { + static const String route = '/main'; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + AppDrawerBuilder(), + //Expanded(child: DashboardScreen()), + Expanded( + flex: 3, + child: InvoiceScreen(), + ), + Expanded( + flex: 5, + child: InvoiceEditScreen(), + ), + ], + ); + } +} diff --git a/lib/ui/auth/login_vm.dart b/lib/ui/auth/login_vm.dart index 550ccd97e..43011f6b2 100644 --- a/lib/ui/auth/login_vm.dart +++ b/lib/ui/auth/login_vm.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_redux/flutter_redux.dart'; +import 'package:invoiceninja_flutter/redux/app/app_actions.dart'; import 'package:invoiceninja_flutter/redux/dashboard/dashboard_actions.dart'; import 'package:invoiceninja_flutter/ui/app/app_builder.dart'; import 'package:invoiceninja_flutter/utils/platforms.dart'; @@ -63,8 +64,14 @@ class LoginVM { ); void _handleLogin(BuildContext context) { + store.dispatch(UpdateLayout(calculateLayout(context))); AppBuilder.of(context).rebuild(); - store.dispatch(ViewDashboard(context)); + + if (isMobile(context)) { + store.dispatch(ViewDashboard(context)); + } else { + store.dispatch(ViewMainScreen(context)); + } } return LoginVM( diff --git a/lib/utils/platforms.dart b/lib/utils/platforms.dart index be03a5974..54b129e1b 100644 --- a/lib/utils/platforms.dart +++ b/lib/utils/platforms.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter_redux/flutter_redux.dart'; import 'package:invoiceninja_flutter/constants.dart'; +import 'package:invoiceninja_flutter/redux/app/app_state.dart'; +import 'package:invoiceninja_flutter/redux/ui/ui_state.dart'; bool isAndroid(BuildContext context) => Theme.of(context).platform == TargetPlatform.android; @@ -17,3 +20,24 @@ String getPlatform(BuildContext context) => String getAppURL(BuildContext context) => isAndroid(context) ? kGoogleStoreUrl : kAppleStoreUrl; + +AppLayout calculateLayout(BuildContext context) { + final size = MediaQuery.of(context).size.shortestSide; + if (size < kMobileLayoutWidth) { + return AppLayout.mobile; + } else if (size > kTabletLayoutWidth) { + return AppLayout.desktop; + } else { + return AppLayout.tablet; + } +} + +AppLayout getLayout(BuildContext context) => + StoreProvider.of(context).state.uiState.layout ?? + AppLayout.mobile; + +bool isMobile(BuildContext context) => getLayout(context) == AppLayout.mobile; + +bool isTablet(BuildContext context) => getLayout(context) == AppLayout.tablet; + +bool isDesktop(BuildContext context) => getLayout(context) == AppLayout.desktop;