diff --git a/.flutter-plugins b/.flutter-plugins new file mode 100644 index 0000000..5116ac0 --- /dev/null +++ b/.flutter-plugins @@ -0,0 +1,2 @@ +path_provider=/Users/ibrahim/development/flutter/.pub-cache/hosted/pub.dartlang.org/path_provider-0.5.0+1/ +shared_preferences=/Users/ibrahim/development/flutter/.pub-cache/hosted/pub.dartlang.org/shared_preferences-0.5.1+2/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f91fbc5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,74 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# Visual Studio Code related +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +pubspec.lock +.flutter-plugins +.packages +.pub-cache/ +.pub/ +/build/ +*.g.dart + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages + +coverage/ \ No newline at end of file diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..281f231 --- /dev/null +++ b/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 8661d8aecd626f7f57ccbcb735553edc05a2e713 + channel: stable + +project_type: package diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000..a304eda --- /dev/null +++ b/AUTHORS @@ -0,0 +1 @@ +TODO: Add Author Info here. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ac07159 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## [0.0.1] - TODO: Add release date. + +* TODO: Describe initial release. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ba75c69 --- /dev/null +++ b/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d0faae3 --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +# flrx + +A new Flutter package project. + +## Getting Started + +This project is a starting point for a Dart +[package](https://flutter.io/developing-packages/), +a library module containing code that can be shared easily across +multiple Flutter or Dart projects. + +For help getting started with Flutter, view our +[online documentation](https://flutter.io/docs), which offers tutorials, +samples, guidance on mobile development, and a full API reference. + +##TODO + +Add Command Line Tools to generate files \ No newline at end of file diff --git a/lib/api/api_client.dart b/lib/api/api_client.dart new file mode 100644 index 0000000..c318014 --- /dev/null +++ b/lib/api/api_client.dart @@ -0,0 +1,17 @@ +import 'package:dio/dio.dart'; + +class ApiClient extends Dio { + ApiClient({BaseOptions options}) : super(options) { + interceptors.add(LogInterceptor( + error: true, + request: true, + requestBody: true, + requestHeader: true, + responseBody: true, + responseHeader: true)); + } + + void setDefaultHeader(Map headers) { + options.headers = headers; + } +} diff --git a/lib/application.dart b/lib/application.dart new file mode 100644 index 0000000..f265949 --- /dev/null +++ b/lib/application.dart @@ -0,0 +1,15 @@ +import 'package:flrx/components/error/error_handler.dart'; +import 'package:flrx/components/error/error_reporter.dart'; +import 'package:flrx/components/registrar/service_registrar.dart'; + +class Application { + static final ServiceRegistrar registrar = ServiceRegistrar(); + + static void init(void Function() initApp, + void Function(ServiceRegistrar) setupSingletons) { + setupSingletons(registrar); + ErrorHandler.init(reporter: get()).runApp(initApp); + } + + static T get() => registrar(); +} diff --git a/lib/components/error/error.dart b/lib/components/error/error.dart new file mode 100644 index 0000000..635e378 --- /dev/null +++ b/lib/components/error/error.dart @@ -0,0 +1,3 @@ +export 'error_handler.dart'; +export 'error_reporter.dart'; +export 'sentry_error_reporter.dart'; diff --git a/lib/components/error/error_handler.dart b/lib/components/error/error_handler.dart new file mode 100644 index 0000000..ba167b5 --- /dev/null +++ b/lib/components/error/error_handler.dart @@ -0,0 +1,60 @@ +import 'dart:async'; + +import 'package:flrx/components/error/error_reporter.dart'; +import 'package:flrx/components/logger/logger.dart'; +import 'package:flrx/config/config.dart'; +import 'package:flutter/widgets.dart'; + +class ErrorHandler { + //Create new Error Handler + factory ErrorHandler.init({@required ErrorReporter reporter}) { + _instance ??= ErrorHandler._internal(reporter); + return _instance; + } + + ErrorHandler._internal(this.reporter) + : reportErrorOnDebug = reporter.reportOnDebug ?? false { + _setupFlutterErrorHandler(); + } + + static void dispose() => _instance = null; + + final ErrorReporter reporter; + + static ErrorHandler _instance; + + final bool reportErrorOnDebug; + + static ErrorHandler get instance { + return _instance; + } + + void _setupFlutterErrorHandler() { + // This captures errors reported by the Flutter framework. + FlutterError.onError = (FlutterErrorDetails details) async { + if (Config.isInDebugMode && !reportErrorOnDebug) { + // In development mode and Debug Mode reporting is disabled. Simply log it + log(details); + } else { + // In production mode report to the application zone + Zone.current.handleUncaughtError(details.exception, details.stack); + } + }; + } + + void runApp(Function appFunction) { + runZoned>(() async { + appFunction(); + }, onError: reportError); + } + + void reportError(dynamic error, StackTrace stackTrace) async { + if (Config.isInDebugMode && !reportErrorOnDebug) { + log('Caught error: $error'); + log(stackTrace); + log('In dev mode. Not reporting Error'); + return; + } + _instance.reporter.reportError(error, stackTrace); + } +} diff --git a/lib/components/error/error_reporter.dart b/lib/components/error/error_reporter.dart new file mode 100644 index 0000000..8a2fb5d --- /dev/null +++ b/lib/components/error/error_reporter.dart @@ -0,0 +1,5 @@ +abstract class ErrorReporter { + bool reportOnDebug = false; + + void reportError(dynamic error, StackTrace stackTrace); +} diff --git a/lib/components/error/sentry_error_reporter.dart b/lib/components/error/sentry_error_reporter.dart new file mode 100644 index 0000000..f4b9acf --- /dev/null +++ b/lib/components/error/sentry_error_reporter.dart @@ -0,0 +1,28 @@ +import 'package:flrx/components/error/error_reporter.dart'; +import 'package:flrx/components/logger/logger.dart'; +import 'package:meta/meta.dart'; +import 'package:sentry/sentry.dart'; + +/// Sentry Error Reporter from [https://flutter.dev/docs/cookbook/maintenance/error-reporting] +class SentryErrorReporter with ErrorReporter { + SentryErrorReporter({@required String dsn}) + : _sentry = SentryClient(dsn: dsn); + + final SentryClient _sentry; + + /// Reports [error] along with its [stackTrace] to Sentry.io. + @override + void reportError(dynamic error, StackTrace stackTrace) async { + log('Reporting to Sentry.io...'); + final SentryResponse response = await _sentry.captureException( + exception: error, + stackTrace: stackTrace, + ); + + if (response.isSuccessful) { + log('Success! Event ID: ${response.eventId}'); + } else { + log('Failed to report to Sentry.io: ${response.error}'); + } + } +} diff --git a/lib/components/localization/base_localizer.dart b/lib/components/localization/base_localizer.dart new file mode 100644 index 0000000..7c7c373 --- /dev/null +++ b/lib/components/localization/base_localizer.dart @@ -0,0 +1,20 @@ +import 'package:flrx/application.dart'; +import 'package:flutter/widgets.dart'; + +abstract class Localizer { + String translate(final BuildContext context, final String key, + {final Map translationParams}); + + String plural( + final BuildContext context, final String key, final int pluralValue); + + void setLocale(final BuildContext context, final Locale locale); + + LocalizationsDelegate getLocalizationDelegate(); +} + +// ignore: unused_element, non_constant_identifier_names +String translate(final BuildContext context, final String key, + {final Map translationParams}) => + Application.get() + .translate(context, key, translationParams: translationParams); diff --git a/lib/components/localization/json_localizer.dart b/lib/components/localization/json_localizer.dart new file mode 100644 index 0000000..3665743 --- /dev/null +++ b/lib/components/localization/json_localizer.dart @@ -0,0 +1,36 @@ +import 'package:flrx/components/localization/base_localizer.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_i18n/flutter_i18n.dart'; +import 'package:flutter_i18n/flutter_i18n_delegate.dart'; + +class JsonLocalizer extends Localizer { + JsonLocalizer( + {@required this.fallbackFile, + @required this.path, + @required this.useCountryCode}); + + final String fallbackFile; + final String path; + final bool useCountryCode; + + @override + String translate(final BuildContext context, final String key, + {final Map translationParams}) => + FlutterI18n.translate(context, key, translationParams); + + @override + String plural(final BuildContext context, final String key, + final int pluralValue) => + FlutterI18n.plural(context, key, pluralValue); + + @override + void setLocale(final BuildContext context, final Locale locale) => + FlutterI18n.refresh(context, locale); + + @override + LocalizationsDelegate getLocalizationDelegate() => + FlutterI18nDelegate( + fallbackFile: fallbackFile, + path: path, + useCountryCode: useCountryCode); +} diff --git a/lib/components/localization/localization.dart b/lib/components/localization/localization.dart new file mode 100644 index 0000000..96132df --- /dev/null +++ b/lib/components/localization/localization.dart @@ -0,0 +1,2 @@ +export 'base_localizer.dart'; +export 'json_localizer.dart'; diff --git a/lib/components/logger/base_logger.dart b/lib/components/logger/base_logger.dart new file mode 100644 index 0000000..c664eac --- /dev/null +++ b/lib/components/logger/base_logger.dart @@ -0,0 +1,7 @@ +import 'package:flrx/application.dart'; + +abstract class Logger { + void log(dynamic message); +} + +void log(dynamic message) => Application.get().log(message); diff --git a/lib/components/logger/console_logger.dart b/lib/components/logger/console_logger.dart new file mode 100644 index 0000000..b5f728c --- /dev/null +++ b/lib/components/logger/console_logger.dart @@ -0,0 +1,12 @@ +import 'package:flrx/components/logger/base_logger.dart'; +import 'package:flutter/widgets.dart'; + +class ConsoleLogger extends Logger { + @override + void log(dynamic message) { + if (message is FlutterErrorDetails) { + FlutterError.dumpErrorToConsole(message); + } else + debugPrint(message.toString()); + } +} diff --git a/lib/components/logger/logger.dart b/lib/components/logger/logger.dart new file mode 100644 index 0000000..d3d6196 --- /dev/null +++ b/lib/components/logger/logger.dart @@ -0,0 +1,2 @@ +export 'base_logger.dart'; +export 'console_logger.dart'; diff --git a/lib/components/registrar/service_registrar.dart b/lib/components/registrar/service_registrar.dart new file mode 100644 index 0000000..576d977 --- /dev/null +++ b/lib/components/registrar/service_registrar.dart @@ -0,0 +1,3 @@ +import 'package:get_it/get_it.dart'; + +class ServiceRegistrar extends GetIt {} diff --git a/lib/config/base_config.dart b/lib/config/base_config.dart new file mode 100644 index 0000000..4da4e0a --- /dev/null +++ b/lib/config/base_config.dart @@ -0,0 +1,21 @@ +import 'package:flrx/config/flavors/flavor_config.dart'; +import 'package:flrx/flrx.dart'; + +abstract class Config { + static bool get isInDebugMode { + bool inDebugMode = false; + assert(inDebugMode = true); + return inDebugMode; + } + + Config(); + + Config of(Flavor flavor); + + static T get() { + return FlavorConfig.instance.configList.firstWhere( + (Config config) => config is T, + orElse: () => + throw Exception("Config $T is not registered in ConfigList")); + } +} diff --git a/lib/config/config.dart b/lib/config/config.dart new file mode 100644 index 0000000..cccba6a --- /dev/null +++ b/lib/config/config.dart @@ -0,0 +1,4 @@ +export 'base_config.dart'; +export 'config_error.dart'; +export 'flavors/flavor.dart'; +export 'flavors/flavor_config.dart'; diff --git a/lib/config/config_error.dart b/lib/config/config_error.dart new file mode 100644 index 0000000..3e24981 --- /dev/null +++ b/lib/config/config_error.dart @@ -0,0 +1,13 @@ +import 'package:flrx/config/flavors/flavor.dart'; +import 'package:meta/meta.dart'; + +class ConfigNotImplementedError extends UnimplementedError { + ConfigNotImplementedError({@required this.configType, @required this.flavor}); + + String configType; + BaseFlavor flavor; + + @override + String toString() => + "ConfigNotImplementedError: $configType Has not been implemented for $flavor"; +} diff --git a/lib/config/flavors/flavor.dart b/lib/config/flavors/flavor.dart new file mode 100644 index 0000000..f9bd138 --- /dev/null +++ b/lib/config/flavors/flavor.dart @@ -0,0 +1,20 @@ +class BaseFlavor { + const BaseFlavor(); +} + +class Flavor extends BaseFlavor { + final String name; + + const Flavor(this.name); + + @override + String toString() => name; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Flavor && runtimeType == other.runtimeType && name == other.name; + + @override + int get hashCode => name.hashCode; +} diff --git a/lib/config/flavors/flavor_config.dart b/lib/config/flavors/flavor_config.dart new file mode 100644 index 0000000..33f5cf7 --- /dev/null +++ b/lib/config/flavors/flavor_config.dart @@ -0,0 +1,31 @@ +import 'package:flrx/config/base_config.dart'; +import 'package:flrx/config/config_error.dart'; +import 'package:flrx/config/flavors/flavor.dart'; +import 'package:meta/meta.dart'; + +class FlavorConfig { + factory FlavorConfig( + {@required BaseFlavor flavor, @required List configList}) { + _instance ??= FlavorConfig._internal(flavor, configList); + return _instance; + } + + FlavorConfig._internal(this.flavor, List configList) { + this.configList = configList.map((Config config) { + Config flavoredConfig = config.of(flavor); + if (flavoredConfig == null) + throw ConfigNotImplementedError( + configType: this.runtimeType.toString(), flavor: flavor); + return flavoredConfig; + }).toList(); + } + + final BaseFlavor flavor; + List configList = []; + + static FlavorConfig _instance; + + static FlavorConfig get instance => _instance; + + static String get name => _instance.flavor.toString(); +} diff --git a/lib/flrx.dart b/lib/flrx.dart new file mode 100644 index 0000000..a61b557 --- /dev/null +++ b/lib/flrx.dart @@ -0,0 +1,17 @@ +library flrx; + +export 'package:flrx/pages/page.dart'; +export 'package:flrx/pages/viewmodel.dart'; + +export 'api/api_client.dart'; +export 'application.dart'; +export 'components/error/error.dart'; +export 'components/localization/localization.dart'; +export 'components/logger/logger.dart'; +export 'config/config.dart'; +export 'navigation/route_retriever.dart'; +export 'navigation/router.dart'; +export 'store/middlewares/future.dart'; +export 'store/store_retriever.dart'; +export 'utils/validator.dart'; +export 'widgets/flavor_app.dart'; diff --git a/lib/navigation/route_retriever.dart b/lib/navigation/route_retriever.dart new file mode 100644 index 0000000..3784df9 --- /dev/null +++ b/lib/navigation/route_retriever.dart @@ -0,0 +1,27 @@ +import 'package:flrx/pages/page_not_found.dart'; +import 'package:fluro/fluro.dart'; +import 'package:flutter/widgets.dart'; + +// TODO(ibrahim-mubarak): Simplify getRoutes Methods +abstract class RouteRetriever { + Map getCommonRoutes() { + return {"/": getRootHandler()}; + } + + Handler getRootHandler(); + + Map getModuleRoutes(); + + Map getRoutes() { + Map resultingRoutes = getCommonRoutes(); + resultingRoutes.addAll(getModuleRoutes()); + return Map.unmodifiable(resultingRoutes); + } + + Handler getNotFoundHandler() { + Handler notFoundHandler = Handler( + handlerFunc: (BuildContext context, Map> params) => + PageNotFound()); + return notFoundHandler; + } +} diff --git a/lib/navigation/router.dart b/lib/navigation/router.dart new file mode 100644 index 0000000..ac4a3f0 --- /dev/null +++ b/lib/navigation/router.dart @@ -0,0 +1,27 @@ +import 'package:flrx/navigation/route_retriever.dart'; +import 'package:fluro/fluro.dart'; + +class AppRouter { + static Router router; + static void init(RouteRetriever retriever) { + router = Router(); + router.notFoundHandler = retriever.getNotFoundHandler(); + retriever.getRoutes().forEach((String route, Handler handler) { + router.define(route, handler: handler); + }); + } + + // TODO(ibrahim-mubarak): Rename this to be more concise and readable in code. + static String generateParamRoute(String route, Map params) { + return route.replaceAllMapped(RegExp("(:[a-zA-Z_]+)"), (Match match) { + if (match.groupCount > 0) { + String paramName = match.group(0).substring(1); + String param = params[paramName]; + if (param == null) + throw ArgumentError( + "Param cannot be null. $paramName is passed as null"); + return param; + } + }); + } +} diff --git a/lib/pages/page.dart b/lib/pages/page.dart new file mode 100644 index 0000000..53f29cc --- /dev/null +++ b/lib/pages/page.dart @@ -0,0 +1,32 @@ +import 'package:flrx/pages/viewmodel.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_redux/flutter_redux.dart'; +import 'package:redux/redux.dart'; + +abstract class Page> { + void onInit(Store store) {} + + void onInitialBuild(V viewModel) {} + + V initViewModel(); + + void onWillChange(BuildContext context, V viewModel) {} + + Widget build(BuildContext context) { + return StoreConnector( + onInit: onInit, + onInitialBuild: onInitialBuild, + onWillChange: (V viewModel) => onWillChange(context, viewModel), + onDidChange: (V viewModel) => onDidChange(context, viewModel), + onDispose: onDispose, + converter: (Store store) => initViewModel()..init(store), + builder: buildContent, + ); + } + + Widget buildContent(BuildContext context, V vm); + + void onDidChange(BuildContext context, V viewModel) {} + + void onDispose(Store store) {} +} diff --git a/lib/pages/page_not_found.dart b/lib/pages/page_not_found.dart new file mode 100644 index 0000000..fd66db7 --- /dev/null +++ b/lib/pages/page_not_found.dart @@ -0,0 +1,11 @@ +import 'package:flrx/components/logger/logger.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +class PageNotFound extends StatelessWidget { + @override + Widget build(BuildContext context) { + log("Route not found"); + return null; + } +} diff --git a/lib/pages/viewmodel.dart b/lib/pages/viewmodel.dart new file mode 100644 index 0000000..12fa4bc --- /dev/null +++ b/lib/pages/viewmodel.dart @@ -0,0 +1,7 @@ +import 'package:redux/redux.dart'; + +abstract class ViewModel { + ViewModel(); + + void init(Store store); +} diff --git a/lib/store/middlewares/future.dart b/lib/store/middlewares/future.dart new file mode 100644 index 0000000..ac8f992 --- /dev/null +++ b/lib/store/middlewares/future.dart @@ -0,0 +1,50 @@ +import 'package:meta/meta.dart'; +import 'package:redux/redux.dart'; + +class FutureAction { + FutureAction({@required this.future}); + + Future

future; + + @override + String toString() => "FutureAction[type = $A, future = $future]"; + + FuturePendingAction _getPendingAction() => FuturePendingAction(); + + FutureSuccessAction _getSuccessAction() => FutureSuccessAction(); + + FutureErrorAction _getErrorAction() => FutureErrorAction(); +} + +class FuturePendingAction { + @override + String toString() => "FuturePendingAction[type = $A]"; +} + +class FutureSuccessAction { + P payload; + + @override + String toString() => "FutureSuccessAction[type = $A, payload = $payload]"; +} + +class FutureErrorAction { + dynamic error; + + @override + String toString() => "FutureErrorAction[type = $A, error = $error]"; +} + +void futureMiddleware( + Store store, dynamic action, NextDispatcher next) { + if (action is FutureAction) { + store.dispatch(action._getPendingAction()); + action.future.then((dynamic value) { + store.dispatch(action._getSuccessAction()..payload = value); + }).catchError((dynamic error) { + store.dispatch(action._getErrorAction()..error = error); + }); + } else { + next(action); + } +} diff --git a/lib/store/store_retriever.dart b/lib/store/store_retriever.dart new file mode 100644 index 0000000..c5f1c17 --- /dev/null +++ b/lib/store/store_retriever.dart @@ -0,0 +1,62 @@ +import 'package:flrx/components/logger/base_logger.dart'; +import 'package:flrx/store/middlewares/future.dart'; +import 'package:logging/logging.dart' as nativeLogger; +import 'package:redux/redux.dart'; +import 'package:redux_logging/redux_logging.dart'; +import 'package:redux_persist/redux_persist.dart'; +import 'package:redux_persist_flutter/redux_persist_flutter.dart'; +import 'package:redux_thunk/redux_thunk.dart'; + +// TODO(ibrahim-mubarak): Should be more configurable +abstract class StoreRetriever { + Reducer getPrimaryReducer(); + + State getInitialState(); + + List> getModuleMiddlewares(); + + Future> retrieveStore() async { + // Load initial state + final State initialState = await getPersistor().load(); + return Store(getPrimaryReducer(), + initialState: initialState ?? getInitialState(), + middleware: getMiddlewares()); + } + + Persistor getPersistor() { + return Persistor( + storage: getStorageEngine(), + serializer: getSerializer(), + debug: true, + throttleDuration: Duration(seconds: 5)); + } + + StateSerializer getSerializer(); + + StorageEngine getStorageEngine() => FlutterStorage(); + + List> getCommonMiddlewares() { + return >[ + _getReduxLoggingMiddleware(), + thunkMiddleware, + futureMiddleware, + getPersistor().createMiddleware() + ]; + } + + List> getMiddlewares() { + List> resultingMiddlewares = getCommonMiddlewares(); + resultingMiddlewares.addAll(getModuleMiddlewares()); + return resultingMiddlewares; + } + + Middleware _getReduxLoggingMiddleware() { + LoggingMiddleware loggingMiddleware = LoggingMiddleware( + formatter: LoggingMiddleware.multiLineFormatter, + ); + loggingMiddleware.logger.onRecord.where((nativeLogger.LogRecord record) { + return record.loggerName == loggingMiddleware.logger.name; + }).listen(log); + return loggingMiddleware; + } +} diff --git a/lib/utils/validator.dart b/lib/utils/validator.dart new file mode 100644 index 0000000..d007bb4 --- /dev/null +++ b/lib/utils/validator.dart @@ -0,0 +1,45 @@ +class Validator { + Validator({this.entityName = "Entity"}) { + _rule = { + Rules.MIN_LENTH: (String value, Map property) { + dynamic minLength = property["minLength"]; + if (value.length < minLength) + return "$entityName should be more than $minLength characters"; + return null; + }, + Rules.EMAIL: _validateEmail + }; + } + List list = List(); + String entityName; + Map _rule = {}; + + Validator add(String ruleName, String value, + [Map property]) { + list.add(_rule[ruleName](value, property)); + return this; + } + + static String _validateEmail(String value, Map property) { + if (value.isEmpty) { + return 'Email should not be empty!'; + } else if (!value.contains('@')) { + return "'@' symbol should be present in the username"; + } + return null; + } + + String runRules() { + for (String rule in list) { + if (rule != null) { + return rule; + } + } + return null; + } +} + +class Rules { + static const String MIN_LENTH = "minLength"; + static const String EMAIL = "email"; +} diff --git a/lib/widgets/flavor_app.dart b/lib/widgets/flavor_app.dart new file mode 100644 index 0000000..f7466c1 --- /dev/null +++ b/lib/widgets/flavor_app.dart @@ -0,0 +1,26 @@ +import 'package:flrx/config/base_config.dart'; +import 'package:flrx/config/flavors/flavor_config.dart'; +import 'package:flutter/widgets.dart'; + +class FlavoredApp extends StatelessWidget { + FlavoredApp({@required this.child, this.showBanner = true, Key key}) + : super(key: key); + + final Widget child; + final bool showBanner; + + @override + Widget build(BuildContext context) { + if (showBanner && Config.isInDebugMode) + return Directionality( + child: Banner( + child: child, + location: BannerLocation.topStart, + message: FlavorConfig.name, + ), + textDirection: TextDirection.ltr, + ); + else + return child; + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..cf65c23 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,63 @@ +name: flrx +description: A Redux Application Framework. +version: 0.0.1 +author: +homepage: + +environment: + sdk: ">=2.1.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + dio: ^2.0.22 + sentry: ^2.2.0 + flutter_i18n: ^0.6.3 + fluro: ^1.4.0 + flutter_redux: ^0.5.3 + redux_logging: ^0.3.0 + redux_thunk: ^0.2.1 + redux_persist_flutter: ^0.8.1 + get_it: ^1.0.3 + +dev_dependencies: + flutter_test: + sdk: flutter + mockito: ^4.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://www.dartlang.org/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + + # To add assets to your package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.io/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.io/assets-and-images/#resolution-aware. + + # To add custom fonts to your package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.io/custom-fonts/#from-packages diff --git a/test/mocks/mock_error_reporter.dart b/test/mocks/mock_error_reporter.dart new file mode 100644 index 0000000..12caa55 --- /dev/null +++ b/test/mocks/mock_error_reporter.dart @@ -0,0 +1,4 @@ +import 'package:flrx/components/error/error_reporter.dart'; +import 'package:mockito/mockito.dart'; + +class MockErrorReporter extends Mock with ErrorReporter {} diff --git a/test/mocks/mock_logger.dart b/test/mocks/mock_logger.dart new file mode 100644 index 0000000..8ab2ed2 --- /dev/null +++ b/test/mocks/mock_logger.dart @@ -0,0 +1,4 @@ +import 'package:flrx/components/logger/logger.dart'; +import 'package:mockito/mockito.dart'; + +class MockLogger extends Mock implements Logger {} diff --git a/test/mocks/mock_route_retriever.dart b/test/mocks/mock_route_retriever.dart new file mode 100644 index 0000000..ae394f4 --- /dev/null +++ b/test/mocks/mock_route_retriever.dart @@ -0,0 +1,39 @@ +import 'package:flrx/navigation/route_retriever.dart'; +import 'package:fluro/src/common.dart'; +import 'package:flutter/widgets.dart'; + +class MockRouteRetriever extends RouteRetriever { + static const String MOCK_LOGIN = "/login"; + static const String MOCK_REGISTER = "/register"; + static const String MOCK_CUSTOMER_HOME = "/customer"; + static const String MOCK_CUSTOMER_PROFILE = "/customer/profile"; + @override + Map getCommonRoutes() { + return { + MOCK_LOGIN: Handler(), + MOCK_REGISTER: Handler(), + }; + } + + @override + Map getModuleRoutes() { + return { + MOCK_CUSTOMER_HOME: Handler(), + MOCK_CUSTOMER_PROFILE: Handler(), + }; + } + + @override + Handler getRootHandler() { + return Handler(); + } + + @override + Handler getNotFoundHandler() { + Handler notFoundHandler = Handler( + handlerFunc: (BuildContext context, Map> params) { + print("Route not found"); + }); + return notFoundHandler; + } +} diff --git a/test/mocks/mock_store_retriever.dart b/test/mocks/mock_store_retriever.dart new file mode 100644 index 0000000..bbe5e2a --- /dev/null +++ b/test/mocks/mock_store_retriever.dart @@ -0,0 +1,74 @@ +import 'dart:convert'; + +import 'package:flrx/store/store_retriever.dart'; +import 'package:redux/src/store.dart'; +import 'package:redux_persist/redux_persist.dart'; +import 'package:redux_persist/src/serialization.dart'; + +class MockStoreRetriever extends StoreRetriever { + @override + MockAppState getInitialState() { + return MockAppState.initialState(); + } + + @override + List> getModuleMiddlewares() { + return >[mockMiddleware]; + } + + @override + Reducer getPrimaryReducer() { + return MockReducer.reduce; + } + + @override + StorageEngine getStorageEngine() => + MemoryStorage(getSerializer().encode(getInitialState())); + + @override + StateSerializer getSerializer() { + return JsonSerializer(MockAppState.fromJson); + } +} + +class MockAppState { + MockAppState(String mockData) { + this.mockData = mockData; + } + + factory MockAppState.initialState() { + return MockAppState(""); + } + + String mockData; + + static MockAppState fromJson(dynamic jsonData) { + return MockAppState(json.decode(jsonData)); + } + + String toJson() => json.encode(mockData); +} + +class MockReducer { + static MockAppState reduce(MockAppState prevMockAppState, dynamic action) { + if (action is MockAction) { + return MockAppState(action.mockData); + } + return prevMockAppState; + } +} + +class MockAction { + String mockData = 'mockData'; +} + +class MockMiddlewareAction {} + +void mockMiddleware( + Store store, dynamic action, NextDispatcher next) { + if (action is MockMiddlewareAction) { + store.dispatch(MockAction); + } else { + next(action); + } +} diff --git a/test/unit/error_handler_test.dart b/test/unit/error_handler_test.dart new file mode 100644 index 0000000..09880e0 --- /dev/null +++ b/test/unit/error_handler_test.dart @@ -0,0 +1,28 @@ +import 'package:flrx/components/error/error.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test_api/test_api.dart'; + +import '../mocks/mock_error_reporter.dart'; +import '../mocks/mock_logger.dart'; + +void main() { + MockLogger logger = MockLogger(); + MockErrorReporter errorReporter = MockErrorReporter(); + ErrorHandler handler; + setUp(() { + handler = ErrorHandler.init(reporter: errorReporter); + }); + + tearDown(ErrorHandler.dispose); + + test("error_handler_init", () { + expect(handler.reporter, errorReporter); + expect(handler, ErrorHandler.instance); + }); + + test("error_handler_run_app", () async { + handler.runApp(() => logger.log("Test Message")); + await untilCalled(logger.log(captureAny)); + verify(logger.log("Test Message")).called(1); + }); +} diff --git a/test/unit/error_reporter_test.dart b/test/unit/error_reporter_test.dart new file mode 100644 index 0000000..d09bbbb --- /dev/null +++ b/test/unit/error_reporter_test.dart @@ -0,0 +1,54 @@ +import 'package:flrx/application.dart'; +import 'package:flrx/components/error/error_handler.dart'; +import 'package:flrx/components/logger/logger.dart'; +import 'package:flutter/foundation.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test_api/test_api.dart'; + +import '../mocks/mock_error_reporter.dart'; +import '../mocks/mock_logger.dart'; + +void main() { + MockErrorReporter reporter = MockErrorReporter(); + Exception testException = Exception("Test Exception"); + FlutterError flutterError = FlutterError("Test Flutter Error"); + Function exceptionBlock = () => throw testException; + Function flutterErrorBlock = () => throw flutterError; + MockLogger logger = MockLogger(); + Application.registrar.registerSingleton(logger); + + group("error_reporter", () { + setUp(() { + reporter = MockErrorReporter(); + reset(reporter); + reset(logger); + }); + + tearDown(ErrorHandler.dispose); + + test('test_catch_exception_debug_mode_reporting', () async { + reporter.reportOnDebug = true; + ErrorHandler.init(reporter: reporter).runApp(exceptionBlock); + await untilCalled(reporter.reportError(captureAny, captureAny)); + verify(reporter.reportError(testException, captureAny)).called(1); + verifyZeroInteractions(logger); + }); + + /// This is not a reliable test + test('test_catch_exception_disabled_debug_mode_reporting', () async { + reporter.reportOnDebug = false; + ErrorHandler.init(reporter: reporter).runApp(exceptionBlock); + await untilCalled(logger.log(captureAny)); + verify(logger.log(captureAny)).called(greaterThanOrEqualTo(1)); + verifyZeroInteractions(reporter); + }); + + test('test_catch_flutter_error_debug_mode_reporting', () async { + reporter.reportOnDebug = true; + ErrorHandler.init(reporter: reporter).runApp(flutterErrorBlock); + await untilCalled(reporter.reportError(captureAny, captureAny)); + verify(reporter.reportError(flutterError, captureAny)).called(1); + verifyZeroInteractions(logger); + }); + }); +} diff --git a/test/unit/future_middleware_test.dart b/test/unit/future_middleware_test.dart new file mode 100644 index 0000000..e11680a --- /dev/null +++ b/test/unit/future_middleware_test.dart @@ -0,0 +1,117 @@ +import 'dart:async'; + +import 'package:flrx/store/middlewares/future.dart'; +import 'package:redux/redux.dart'; +import 'package:test_api/test_api.dart'; + +class Action {} + +void main() { + group('Future Middleware', () { + Store store; + List logs; + void loggingMiddleware( + Store store, dynamic action, NextDispatcher next) { + logs.add(action.toString()); + next(action); + } + + String futureReducer(String state, dynamic action) { + if (action is FuturePendingAction) { + return action.toString(); + } else if (action is FutureSuccessAction) { + return action.payload; + } else if (action is FutureErrorAction) { + return action.error.toString(); + } else { + return state; + } + } + + setUp(() { + store = Store(futureReducer, middleware: >[ + loggingMiddleware, + futureMiddleware + ]); + logs = []; + }); + +// test('is a Redux Middleware', () { +// expect(futureMiddleware, Middleware); +// }); + + group('FutureAction', () { + test('can synchronously dispatch a pending action', () { + final FutureAction action = + FutureAction( + future: Future.value("Fetch Complete")); + store.dispatch(action); + expect(store.state, FuturePendingAction().toString()); + }); + + test( + 'dispatches a FutureSuccessAction if the future completes successfully', + () async { + const String dispatchedAction = "Friend"; + final Future future = Future.value(dispatchedAction); + final FutureAction action = + FutureAction(future: future); + + store.dispatch(action); + await future; + expect(store.state, dispatchedAction); + }); + + test('dispatches a FutureRejectedAction if the future returns an error', + () { + final Exception exception = Exception("Error Message"); + final Future future = Future.error(exception); + final FutureAction action = + FutureAction(future: future); + + store.dispatch(action); + expect( + future.catchError((_) => store.state), + completion(contains(exception.toString())), + ); + }); + + test('returns the result of the Future after it has been dispatched', + () async { + const String dispatchedAction = "Friend"; + final Future future = Future.value(dispatchedAction); + final FutureAction action = + FutureAction(future: future); + + store.dispatch(action); + expect(await action.future, dispatchedAction); + }); + + test('returns the error of the Future after it has been dispatched', + () async { + final Exception exception = Exception("Khaaaaaaaaaan"); + final Future future = Future.error(exception); + final FutureAction action = + FutureAction(future: future); + + store.dispatch(action); + expect(future.catchError((_) => store.state), + completion(contains(exception.toString()))); + }); + + test('dispatchs initial action through Store.dispatch', () async { + FutureAction action = FutureAction( + future: Future.value("Friend")); + + store.dispatch(action); + final String fulfilledAction = await action.future; + expect(logs, [ + action.toString(), + FuturePendingAction().toString(), + (FutureSuccessAction()..payload = fulfilledAction) + .toString(), + ]); + }); + }); + }); +} diff --git a/test/unit/route_retriever_test.dart b/test/unit/route_retriever_test.dart new file mode 100644 index 0000000..0f53017 --- /dev/null +++ b/test/unit/route_retriever_test.dart @@ -0,0 +1,15 @@ +import 'package:flrx/navigation/route_retriever.dart'; +import 'package:fluro/fluro.dart'; +import 'package:test_api/test_api.dart'; + +import '../mocks/mock_route_retriever.dart'; + +void main() { + RouteRetriever retriever = MockRouteRetriever(); + test('count_total_routes_test', () { + Map commonRoutes = retriever.getCommonRoutes(); + Map moduleRoutes = retriever.getModuleRoutes(); + expect(commonRoutes.length + moduleRoutes.length, + retriever.getRoutes().length); + }); +} diff --git a/test/unit/router_test.dart b/test/unit/router_test.dart new file mode 100644 index 0000000..602f978 --- /dev/null +++ b/test/unit/router_test.dart @@ -0,0 +1,93 @@ +import 'package:flrx/navigation/route_retriever.dart'; +import 'package:flrx/navigation/router.dart'; +import 'package:fluro/fluro.dart'; +import 'package:test_api/test_api.dart'; + +import '../mocks/mock_route_retriever.dart'; + +void main() { + group('GenerateParamRoute function test', () { + test('no_param_route_test', () { + String route = "/provider"; + String paramRoute = + AppRouter.generateParamRoute(route, {}); + String expectedRoute = "/provider"; + expect(paramRoute, expectedRoute); + }); + + test('one_param_route_test', () { + String route = "/provider/:providerId"; + String paramRoute = AppRouter.generateParamRoute( + route, {'providerId': '1'}); + String expectedRoute = "/provider/1"; + expect(paramRoute, expectedRoute); + }); + + test('multiple_param_route_test', () { + String route = "/provider/:providerId/booking/:bookingId"; + String paramRoute = AppRouter.generateParamRoute( + route, {'providerId': '1', 'bookingId': '2'}); + String expectedRoute = "/provider/1/booking/2"; + expect(paramRoute, expectedRoute); + }); + + test('multiple_continuous_params_route_test', () { + String route = "/provider/:providerId/:bookingId"; + String paramRoute = AppRouter.generateParamRoute( + route, {'providerId': '1', 'bookingId': '2'}); + String expectedRoute = "/provider/1/2"; + expect(paramRoute, expectedRoute); + }); + + test('single_null_param_route_test', () { + String paramName = "providerId"; + expect(() { + String route = "/provider/:$paramName"; + return AppRouter.generateParamRoute( + route, {paramName: null}); + }, throwsA(predicate((Error e) { + return e is ArgumentError && + e.message == 'Param cannot be null. $paramName is passed as null'; + }))); + }); + + test('single_paramname_no_param_route_test', () { + String paramName = "providerId"; + expect(() { + String route = "/provider/:$paramName"; + return AppRouter.generateParamRoute(route, {}); + }, throwsA(predicate((Error e) { + return e is ArgumentError && + e.message == 'Param cannot be null. $paramName is passed as null'; + }))); + }); + + test('one_param_with_underscore_route_test', () { + String route = "/provider/:provider_id"; + String paramRoute = AppRouter.generateParamRoute( + route, {'provider_id': '1'}); + String expectedRoute = "/provider/1"; + expect(paramRoute, expectedRoute); + }); + }); + group('AppRouter init test', () { + RouteRetriever retriever = MockRouteRetriever(); + AppRouter.init(retriever); + test('common_routes_test', () { + AppRouteMatch match = + AppRouter.router.match(MockRouteRetriever.MOCK_LOGIN); + expect(match.route.route, MockRouteRetriever.MOCK_LOGIN); + }); + + test('module_routes_test', () { + AppRouteMatch match = + AppRouter.router.match(MockRouteRetriever.MOCK_CUSTOMER_HOME); + expect(match.route.route, MockRouteRetriever.MOCK_CUSTOMER_HOME); + }); + + test('unavailable_routes_test', () { + AppRouteMatch match = AppRouter.router.match("/unknown"); + expect(match, null); + }); + }); +} diff --git a/test/unit/store_retriever_test.dart b/test/unit/store_retriever_test.dart new file mode 100644 index 0000000..74ab0e4 --- /dev/null +++ b/test/unit/store_retriever_test.dart @@ -0,0 +1,38 @@ +import 'package:flrx/application.dart'; +import 'package:flrx/components/logger/logger.dart'; +import 'package:flrx/store/store_retriever.dart'; +import 'package:redux/redux.dart'; +import 'package:test_api/test_api.dart'; + +import '../mocks/mock_store_retriever.dart'; + +void main() async { + Application.registrar.registerSingleton(ConsoleLogger()); + StoreRetriever storeRetriever = MockStoreRetriever(); + + test('count middlewares test', () { + List> commonMiddlewares = + storeRetriever.getCommonMiddlewares(); + List> moduleMiddlewares = + storeRetriever.getModuleMiddlewares(); + List> allMiddlewares = + storeRetriever.getMiddlewares(); + expect(commonMiddlewares.length + moduleMiddlewares.length, + allMiddlewares.length); + }); + + test('test store reducer', () async { + Store store = await storeRetriever.retrieveStore(); + expect(store.reducer, storeRetriever.getPrimaryReducer()); + }); + + test('test store retreiver primary reducer', () { + Reducer reducer = storeRetriever.getPrimaryReducer(); + expect(reducer, MockReducer.reduce); + }); + + test('test store retreiver initial state', () { + expect(storeRetriever.getInitialState().mockData, + MockAppState.initialState().mockData); + }); +}