diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 00000000..0cafc1cd --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1 @@ +.venv/ \ No newline at end of file diff --git a/mobile/.gitignore b/mobile/.gitignore index 9d532b18..eca61f42 100644 --- a/mobile/.gitignore +++ b/mobile/.gitignore @@ -8,6 +8,7 @@ .buildlog/ .history .svn/ +.vscode/ # IntelliJ related *.iml diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 11655b66..ad72d679 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -1,117 +1,7 @@ import 'package:flutter/material.dart'; -void main() { - runApp(MyApp()); -} - -class MyApp extends StatelessWidget { - // This widget is the root of your application. - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Flutter Demo', - theme: ThemeData( - // This is the theme of your application. - // - // Try running your application with "flutter run". You'll see the - // application has a blue toolbar. Then, without quitting the app, try - // changing the primarySwatch below to Colors.green and then invoke - // "hot reload" (press "r" in the console where you ran "flutter run", - // or simply save your changes to "hot reload" in a Flutter IDE). - // Notice that the counter didn't reset back to zero; the application - // is not restarted. - primarySwatch: Colors.blue, - // This makes the visual density adapt to the platform that you run - // the app on. For desktop platforms, the controls will be smaller and - // closer together (more dense) than on mobile platforms. - visualDensity: VisualDensity.adaptivePlatformDensity, - ), - home: MyHomePage(title: 'Flutter Demo Home Page'), - ); - } -} - -class MyHomePage extends StatefulWidget { - MyHomePage({Key key, this.title}) : super(key: key); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". +import 'src/app.dart'; - final String title; - - @override - _MyHomePageState createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); - } - - @override - Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Invoke "debug painting" (press "p" in the console, choose the - // "Toggle Debug Paint" action from the Flutter Inspector in Android - // Studio, or the "Toggle Debug Paint" command in Visual Studio Code) - // to see the wireframe for each widget. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'You have pushed the button this many times:', - ), - Text( - '$_counter', - style: Theme.of(context).textTheme.headline4, - ), - ], - ), - ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: Icon(Icons.add), - ), // This trailing comma makes auto-formatting nicer for build methods. - ); - } +void main() { + runApp(App()); } diff --git a/mobile/lib/src/app.dart b/mobile/lib/src/app.dart new file mode 100644 index 00000000..be3bfef4 --- /dev/null +++ b/mobile/lib/src/app.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'presentation/wallets/wallets_route.dart'; +import 'locator.dart'; +import 'theme.dart'; + +class App extends StatelessWidget { + App() { + registerServices(); + } + + @override + Widget build(BuildContext context) { + /// Cubits are provided globally down all of the context tree + return MultiBlocProvider( + providers: createBlocProviders(), + child: MaterialApp( + title: 'PolyDodo', + theme: theme, + home: WalletsRoute(), + initialRoute: WalletsRoute.name, + routes: { + WalletsRoute.name: (context) => WalletsRoute(), + }, + ), + ); + } +} diff --git a/mobile/lib/src/application/wallets/wallets_cubit.dart b/mobile/lib/src/application/wallets/wallets_cubit.dart new file mode 100644 index 00000000..6ab7d3e3 --- /dev/null +++ b/mobile/lib/src/application/wallets/wallets_cubit.dart @@ -0,0 +1,63 @@ +import 'dart:math'; + +import 'package:bloc/bloc.dart'; + +import 'package:polydodo/src/application/wallets/wallets_state.dart'; +import 'package:polydodo/src/domain/unique_id.dart'; +import 'package:polydodo/src/domain/wallet/i_wallet_repository.dart'; +import 'package:polydodo/src/domain/wallet/money.dart'; +import 'package:polydodo/src/domain/wallet/owner.dart'; +import 'package:polydodo/src/domain/wallet/wallet.dart'; + +class WalletsCubit extends Cubit { + final IWalletRepository _walletRepository; + + WalletsCubit(this._walletRepository) : super(WalletsInitial()) { + createNewWallets(); + emit(WalletsLoadInProgress()); + _walletRepository + .watch() + .listen((wallets) => emit(WalletsLoadSuccess(wallets))) + .onError((e) => emit(WalletsLoadFailure(e))); + } + + // Does not yield another state + void transfer(Wallet sender, Wallet receiver, Money amount) async { + try { + sender.transfer(amount, receiver); + } catch (e) { + emit(WalletsTransferFailure(e)); + } + await Future.wait([ + _walletRepository.store(sender), + _walletRepository.store(receiver), + ]).catchError((e) => emit(WalletsTransferFailure(e))); + } + + Future> createNewWallets() => Future.wait([ + _walletRepository.store( + Wallet( + UniqueId(), + Owner( + id: UniqueId.from('1'), + firstName: 'Bob', + lastName: 'Skiridovsky', + age: 25, + ), + Money(Random().nextDouble() * 10, Currency.cad), + ), + ), + _walletRepository.store( + Wallet( + UniqueId(), + Owner( + id: UniqueId.from('2'), + firstName: 'Alice', + lastName: 'Pogo', + age: 35, + ), + Money(Random().nextDouble() * 1000, Currency.cad), + ), + ) + ]); +} diff --git a/mobile/lib/src/application/wallets/wallets_state.dart b/mobile/lib/src/application/wallets/wallets_state.dart new file mode 100644 index 00000000..01788a39 --- /dev/null +++ b/mobile/lib/src/application/wallets/wallets_state.dart @@ -0,0 +1,25 @@ +import 'package:polydodo/src/domain/wallet/wallet.dart'; + +abstract class WalletsState {} + +class WalletsInitial extends WalletsState {} + +class WalletsLoadInProgress extends WalletsState {} + +class WalletsLoadSuccess extends WalletsState { + final List wallets; + + WalletsLoadSuccess(this.wallets); +} + +class WalletsLoadFailure extends WalletsState { + final Exception cause; + + WalletsLoadFailure(this.cause); +} + +class WalletsTransferFailure extends WalletsState { + final Exception cause; + + WalletsTransferFailure(this.cause); +} diff --git a/mobile/lib/src/domain/unique_id.dart b/mobile/lib/src/domain/unique_id.dart new file mode 100644 index 00000000..9b301f89 --- /dev/null +++ b/mobile/lib/src/domain/unique_id.dart @@ -0,0 +1,20 @@ +import 'package:equatable/equatable.dart'; +import 'package:uuid/uuid.dart'; + +class UniqueId extends Equatable { + final String _id; + + UniqueId() : _id = Uuid().v4(); + + UniqueId.from(String unique) + : assert(unique != null), + _id = unique; + + String get() => _id; + + @override + List get props => [_id]; + + @override + bool get stringify => true; +} diff --git a/mobile/lib/src/domain/wallet/constants.dart b/mobile/lib/src/domain/wallet/constants.dart new file mode 100644 index 00000000..595ccd29 --- /dev/null +++ b/mobile/lib/src/domain/wallet/constants.dart @@ -0,0 +1 @@ +const cadToUsdRate = 1.32; diff --git a/mobile/lib/src/domain/wallet/i_wallet_repository.dart b/mobile/lib/src/domain/wallet/i_wallet_repository.dart new file mode 100644 index 00000000..d1f87cff --- /dev/null +++ b/mobile/lib/src/domain/wallet/i_wallet_repository.dart @@ -0,0 +1,6 @@ +import 'wallet.dart'; + +abstract class IWalletRepository { + Future store(Wallet wallet); + Stream> watch(); +} diff --git a/mobile/lib/src/domain/wallet/money.dart b/mobile/lib/src/domain/wallet/money.dart new file mode 100644 index 00000000..1f760cea --- /dev/null +++ b/mobile/lib/src/domain/wallet/money.dart @@ -0,0 +1,57 @@ +import 'package:equatable/equatable.dart'; + +import 'constants.dart'; + +enum Currency { usd, cad } + +Map currencyToString = { + Currency.usd: 'USD', + Currency.cad: 'CAD', +}; + +// Value object usually extends equatable +class Money extends Equatable { + static const _min = 0; + + final double value; + final Currency currency; + + Money(this.value, this.currency) + : assert(value != null), + assert(currency != null) { + // Validation that money cannot be in an impossible state + if (value <= _min) { + Exception('Money amount cannot be inferior to $_min'); + } + } + + double get usd { + if (currency == Currency.cad) { + return value / cadToUsdRate; + } + return value; + } + + double get cad { + if (currency == Currency.usd) { + return value * cadToUsdRate; + } + return value; + } + + Money convertTo(Currency currency) { + if (currency == Currency.usd) { + return Money(usd, currency); + } + return Money(cad, currency); + } + + /// Gives back result into left operand's currency + Money operator +(Money other) => + Money(this.usd + other.usd, Currency.usd).convertTo(this.currency); + Money operator -(Money other) => + Money(this.usd - other.usd, Currency.usd).convertTo(this.currency); + + @override + List get props => [usd]; +} diff --git a/mobile/lib/src/domain/wallet/owner.dart b/mobile/lib/src/domain/wallet/owner.dart new file mode 100644 index 00000000..a1360dbf --- /dev/null +++ b/mobile/lib/src/domain/wallet/owner.dart @@ -0,0 +1,32 @@ +import 'package:meta/meta.dart'; + +import 'package:polydodo/src/domain/unique_id.dart'; + +class Owner { + static const _legalAge = 16; + + final UniqueId id; + final String firstName; + final String lastName; + final int age; + + Owner({ + @required this.id, + @required this.firstName, + @required this.lastName, + @required this.age, + }) : assert(id != null), + assert(firstName != null), + assert(lastName != null) { + if (firstName.length <= 0 || lastName.length <= 0) { + throw Exception('First and last name cannot be empty'); + } + if (!_hasLegalAge()) { + throw Exception('This owner does not have the legal age'); + } + } + + bool _hasLegalAge() => age >= _legalAge; + + // In a good model entity, we should have behavorial method... +} diff --git a/mobile/lib/src/domain/wallet/wallet.dart b/mobile/lib/src/domain/wallet/wallet.dart new file mode 100644 index 00000000..0c1dffcf --- /dev/null +++ b/mobile/lib/src/domain/wallet/wallet.dart @@ -0,0 +1,35 @@ +import 'package:polydodo/src/domain/unique_id.dart'; + +import 'money.dart'; +import 'owner.dart'; + +// We call Wallet an aggregate root because it own other entities +class Wallet { + final UniqueId id; + final Owner owner; + Money money; + + Wallet(this.id, this.owner, this.money); + + // Wallet is an entity and therefore it must have a behavior. It is here that + // business logic appears. + void transfer(Money amount, Wallet receiver) { + if (money.usd - amount.usd < 0) { + throw Exception( + 'Not enough money in this wallet to perform this transaction', + ); + } + money -= amount; + receiver._receive(amount); + } + + void _receive(Money amount) { + money += amount; + } + + @override + bool operator ==(other) => this.id == other.id; + + @override + int get hashCode => super.hashCode; +} diff --git a/mobile/lib/src/infrastructure/mock_wallet_repository.dart b/mobile/lib/src/infrastructure/mock_wallet_repository.dart new file mode 100644 index 00000000..3e8cc516 --- /dev/null +++ b/mobile/lib/src/infrastructure/mock_wallet_repository.dart @@ -0,0 +1,22 @@ +import 'dart:async'; + +import 'package:polydodo/src/domain/wallet/i_wallet_repository.dart'; +import 'package:polydodo/src/domain/wallet/wallet.dart'; + +class MockWalletRepository implements IWalletRepository { + static List walletsMockPersistency = []; + final streamController = StreamController>(); + + @override + Future store(Wallet wallet) async { + await Future.delayed(Duration(milliseconds: 400)); + final idx = walletsMockPersistency.indexOf(wallet); + idx == -1 + ? walletsMockPersistency.add(wallet) + : walletsMockPersistency[idx] = wallet; + streamController.add(walletsMockPersistency); + } + + @override + Stream> watch() => streamController.stream; +} diff --git a/mobile/lib/src/locator.dart b/mobile/lib/src/locator.dart new file mode 100644 index 00000000..bac78b6f --- /dev/null +++ b/mobile/lib/src/locator.dart @@ -0,0 +1,25 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get_it/get_it.dart'; + +import 'package:polydodo/src/domain/wallet/i_wallet_repository.dart'; +import 'package:polydodo/src/infrastructure/mock_wallet_repository.dart'; + +import 'application/wallets/wallets_cubit.dart'; + +/// Private GetIt instance as we want all DI to be performed here in this file +final _serviceLocator = GetIt.asNewInstance(); + +void registerServices() { + _serviceLocator.registerSingleton( + MockWalletRepository(), + ); +} + +/// This function creates all the BlocProviders used in this app +List createBlocProviders() => [ + BlocProvider( + create: (context) => WalletsCubit( + _serviceLocator.get(), + ), + ), + ]; diff --git a/mobile/lib/src/presentation/wallets/wallet_widget.dart b/mobile/lib/src/presentation/wallets/wallet_widget.dart new file mode 100644 index 00000000..426e3dc6 --- /dev/null +++ b/mobile/lib/src/presentation/wallets/wallet_widget.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:polydodo/src/domain/wallet/money.dart'; +import 'package:polydodo/src/domain/wallet/wallet.dart'; + +class WalletWidget extends StatelessWidget { + final Wallet wallet; + + const WalletWidget({Key key, @required this.wallet}) : super(key: key); + + @override + Widget build(BuildContext context) { + final currency = currencyToString[wallet.money.currency]; + return Container( + child: Column( + children: [ + Text('${wallet.owner.firstName} ${wallet.owner.lastName}'), + Text('ID: ${wallet.owner.id.get()}'), + Text('${wallet.money.value.toStringAsFixed(2)} $currency'), + ], + ), + ); + } +} diff --git a/mobile/lib/src/presentation/wallets/wallets_route.dart b/mobile/lib/src/presentation/wallets/wallets_route.dart new file mode 100644 index 00000000..c5e9af1e --- /dev/null +++ b/mobile/lib/src/presentation/wallets/wallets_route.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:polydodo/src/application/wallets/wallets_cubit.dart'; +import 'package:polydodo/src/application/wallets/wallets_state.dart'; +import 'package:polydodo/src/domain/wallet/constants.dart'; +import 'package:polydodo/src/domain/wallet/money.dart'; + +import 'wallets_widget.dart'; + +class WalletsRoute extends StatelessWidget { + static const name = 'walletsRoute'; + + WalletsRoute({Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Polydodo')), + body: BlocConsumer( + listener: (context, state) { + if (state is WalletsLoadFailure) { + Scaffold.of(context).showSnackBar(SnackBar( + content: Text('Unable to load Wallets because ${state.cause}'), + )); + } else if (state is WalletsTransferFailure) { + Scaffold.of(context).showSnackBar(SnackBar( + content: Text('Unable to transfer money because ${state.cause}'), + )); + } + }, + builder: (context, state) { + return Center( + child: Column( + children: [ + Text('CAD to USD Rate: $cadToUsdRate'), + WalletsWidget(state: state), + if (state is WalletsLoadSuccess) + _buildTransferButton(context, state), + ], + ), + ); + }, + ), + ); + } + + Widget _buildTransferButton(BuildContext context, WalletsLoadSuccess state) { + final sender = state.wallets[0]; + final receiver = state.wallets[1]; + return RaisedButton( + child: Text( + 'Send 1 USD from ${sender.owner.firstName} to ${receiver.owner.firstName}', + ), + onPressed: () => BlocProvider.of(context).transfer( + sender, + receiver, + Money(1, Currency.usd), + ), + ); + } +} diff --git a/mobile/lib/src/presentation/wallets/wallets_widget.dart b/mobile/lib/src/presentation/wallets/wallets_widget.dart new file mode 100644 index 00000000..3e8b643d --- /dev/null +++ b/mobile/lib/src/presentation/wallets/wallets_widget.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:polydodo/src/application/wallets/wallets_state.dart'; + +import 'wallet_widget.dart'; + +class WalletsWidget extends StatelessWidget { + final WalletsState state; + + const WalletsWidget({Key key, @required this.state}) : super(key: key); + + @override + Widget build(BuildContext context) { + if (state is WalletsLoadSuccess) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: (state as WalletsLoadSuccess) + .wallets + .map((wallet) => Container( + child: WalletWidget(wallet: wallet), + margin: EdgeInsets.all(24), + )) + .toList(), + ); + } + + return SizedBox( + child: CircularProgressIndicator(), + height: 50, + width: 50, + ); + } +} diff --git a/mobile/lib/src/theme.dart b/mobile/lib/src/theme.dart new file mode 100644 index 00000000..893c05cd --- /dev/null +++ b/mobile/lib/src/theme.dart @@ -0,0 +1,6 @@ +import 'package:flutter/material.dart'; + +final theme = ThemeData( + primarySwatch: Colors.blue, + visualDensity: VisualDensity.adaptivePlatformDensity, +); diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 03161685..cf28699b 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -99,6 +99,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.1.3" + equatable: + dependency: "direct main" + description: + name: equatable + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.5" fake_async: dependency: transitive description: @@ -137,6 +144,13 @@ packages: description: flutter source: sdk version: "0.0.0" + get_it: + dependency: "direct main" + description: + name: get_it + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.4" hive: dependency: "direct main" description: @@ -152,7 +166,7 @@ packages: source: hosted version: "0.12.10-nullsafety" meta: - dependency: transitive + dependency: "direct main" description: name: meta url: "https://pub.dartlang.org" @@ -225,7 +239,7 @@ packages: name: source_span url: "https://pub.dartlang.org" source: hosted - version: "1.8.0-nullsafety" + version: "1.8.0-nullsafety.1" stack_trace: dependency: transitive description: @@ -275,6 +289,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.2.4" + uuid: + dependency: "direct main" + description: + name: uuid + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.2" vector_math: dependency: transitive description: @@ -283,5 +304,5 @@ packages: source: hosted version: "2.1.0-nullsafety.2" sdks: - dart: ">=2.10.0-0.0.dev <2.10.0" + dart: ">=2.10.0-137.0 <2.10.0" flutter: ">=1.16.0 <2.0.0" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 5409c88d..c7f7dd29 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -1,9 +1,9 @@ -name: Polydodo +name: polydodo description: A new Flutter project. # The following line prevents the package from being accidentally published to # pub.dev using `pub publish`. This is preferred for private packages. -publish_to: "none" # Remove this line if you wish to publish to pub.dev +publish_to: 'none' # Remove this line if you wish to publish to pub.dev # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 @@ -18,7 +18,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev version: 1.0.0+1 environment: - sdk: ">=2.7.0 <3.0.0" + sdk: '>=2.7.0 <3.0.0' dependencies: flutter: @@ -26,16 +26,20 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^0.1.3 - protobuf: ^1.0.1 - hive: ^1.4.4 - usb_serial: ^0.2.4 - flutter_blue: ^0.7.2 + archive: ^2.0.13 + battery: ^1.0.5 bloc: ^6.0.3 + cupertino_icons: ^0.1.3 + equatable: ^1.2.5 flutter_bloc: ^6.0.5 - battery: ^1.0.5 + flutter_blue: ^0.7.2 + get_it: ^4.0.4 + hive: ^1.4.4 + meta: ^1.1.8 + protobuf: ^1.0.1 share: ^0.6.5 - archive: ^2.0.13 + usb_serial: ^0.2.4 + uuid: ^2.2.2 dev_dependencies: flutter_test: diff --git a/mobile/test/widget_test.dart b/mobile/test/widget_test.dart index 53a930a0..295d8358 100644 --- a/mobile/test/widget_test.dart +++ b/mobile/test/widget_test.dart @@ -1,30 +1,5 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility that Flutter provides. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:Polydodo/main.dart'; - void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); + testWidgets('Test', (WidgetTester tester) async {}); }