mirror of
https://github.com/meditohq/medito-app.git
synced 2024-10-26 20:03:25 +03:00
Merge branch 'develop' of https://github.com/meditohq/medito-app into develop
This commit is contained in:
commit
e702cee519
2
.gitignore
vendored
2
.gitignore
vendored
@ -60,7 +60,7 @@ ios/*
|
||||
**/ios/Flutter/App.framework
|
||||
**/ios/Flutter/Flutter.framework
|
||||
**/ios/Flutter/Generated.xcconfig
|
||||
ios/Flutter/flutter_export_environment.sh
|
||||
**/ios/Flutter/flutter_export_environment.sh
|
||||
**/ios/Flutter/app.flx
|
||||
**/ios/Flutter/app.zip
|
||||
**/ios/Flutter/flutter_assets/
|
||||
|
@ -13,7 +13,9 @@ Affero GNU General Public License for more details.
|
||||
You should have received a copy of the Affero GNU General Public License
|
||||
along with Medito App. If not, see <https://www.gnu.org/licenses/>.*/
|
||||
|
||||
class ApiResponse<T> {
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class ApiResponse<T> extends Equatable {
|
||||
Status status;
|
||||
T body;
|
||||
String message;
|
||||
@ -30,6 +32,9 @@ class ApiResponse<T> {
|
||||
String toString() {
|
||||
return 'Status : $status \n Message : $message \n Data : $body';
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object> get props => [status, body, message];
|
||||
}
|
||||
|
||||
enum Status { LOADING, COMPLETED, ERROR }
|
@ -19,16 +19,16 @@ import 'package:Medito/network/api_response.dart';
|
||||
import 'package:Medito/network/home/home_repo.dart';
|
||||
import 'package:Medito/network/home/menu_response.dart';
|
||||
import 'package:Medito/utils/utils.dart';
|
||||
import 'package:connectivity/connectivity.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class HomeBloc {
|
||||
HomeRepo _repo;
|
||||
final HomeRepo _repo;
|
||||
StreamController<ApiResponse<MenuResponse>> menuList;
|
||||
StreamController<bool> connectionStreamController;
|
||||
StreamController<String> titleText;
|
||||
|
||||
HomeBloc() {
|
||||
_repo = HomeRepo();
|
||||
|
||||
HomeBloc({@required HomeRepo repo}) : _repo = repo {
|
||||
menuList = StreamController.broadcast()..sink.add(ApiResponse.loading());
|
||||
titleText = StreamController.broadcast();
|
||||
connectionStreamController = StreamController.broadcast();
|
||||
@ -58,4 +58,10 @@ class HomeBloc {
|
||||
return 'Good evening';
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
menuList?.close();
|
||||
connectionStreamController?.close();
|
||||
titleText?.close();
|
||||
}
|
||||
}
|
||||
|
@ -1,15 +1,19 @@
|
||||
class MenuResponse {
|
||||
List<MenuData> data;
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class MenuResponse extends Equatable {
|
||||
final List<MenuData> data;
|
||||
|
||||
MenuResponse({this.data});
|
||||
|
||||
MenuResponse.fromJson(Map<String, dynamic> json) {
|
||||
factory MenuResponse.fromJson(Map<String, dynamic> json) {
|
||||
if (json['data'] != null) {
|
||||
data = <MenuData>[];
|
||||
var _data = <MenuData>[];
|
||||
json['data'].forEach((v) {
|
||||
data.add(MenuData.fromJson(v));
|
||||
_data.add(MenuData.fromJson(v));
|
||||
});
|
||||
return MenuResponse(data: _data);
|
||||
}
|
||||
return MenuResponse(data: []);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
@ -19,19 +23,23 @@ class MenuResponse {
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object> get props => [data];
|
||||
}
|
||||
|
||||
class MenuData {
|
||||
String itemLabel;
|
||||
String itemType;
|
||||
String itemPath;
|
||||
class MenuData extends Equatable {
|
||||
final String itemLabel;
|
||||
final String itemType;
|
||||
final String itemPath;
|
||||
|
||||
MenuData({itemLabel, itemType, itemPath});
|
||||
MenuData({this.itemLabel, this.itemType, this.itemPath});
|
||||
|
||||
MenuData.fromJson(Map<String, dynamic> json) {
|
||||
itemLabel = json['item_label'];
|
||||
itemType = json['item_type'];
|
||||
itemPath = json['item_path'];
|
||||
factory MenuData.fromJson(Map<String, dynamic> json) {
|
||||
var _itemLabel = json['item_label'];
|
||||
var _itemType = json['item_type'];
|
||||
var _itemPath = json['item_path'];
|
||||
return MenuData(itemLabel: _itemLabel, itemType: _itemType, itemPath: _itemPath);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
@ -41,4 +49,7 @@ class MenuData {
|
||||
data['item_path'] = itemPath;
|
||||
return data;
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object> get props => [itemLabel, itemType, itemPath];
|
||||
}
|
||||
|
@ -4,6 +4,8 @@ const String EMPTY_FAVORITES_MESSAGE = 'Favourites are coming soon.';
|
||||
const String WELL_DONE_COPY = 'Well done for meditating today!';
|
||||
const String WELL_DONE_SUBTITLE = 'The mind is everything. What you think, you become.';
|
||||
const String LOADING = 'Loading';
|
||||
const String RETRYING = 'Retrying...';
|
||||
const String TRY_AGAIN = 'Try again';
|
||||
const String SHARE_TEXT = "I just meditated with Medito. Try it out, it's like Headspace and Calm but it's 100% free and not-for-profit! https://medito.app";
|
||||
const String LOADING_ERROR = "It looks like you're offline or there was little hiccup from our end.";
|
||||
const String CHECK_CONNECTION = 'Please check your connection and try again';
|
||||
|
@ -17,6 +17,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:Medito/network/api_response.dart';
|
||||
import 'package:Medito/network/home/home_bloc.dart';
|
||||
import 'package:Medito/network/home/home_repo.dart';
|
||||
import 'package:Medito/network/home/menu_response.dart';
|
||||
import 'package:Medito/tracking/tracking.dart';
|
||||
import 'package:Medito/network/user/user_utils.dart';
|
||||
@ -41,7 +42,7 @@ class HomeWidget extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _HomeWidgetState extends State<HomeWidget> {
|
||||
final _bloc = HomeBloc();
|
||||
final _bloc = HomeBloc(repo: HomeRepo());
|
||||
|
||||
final GlobalKey<AnnouncementBannerState> _announceKey = GlobalKey();
|
||||
|
||||
@ -233,6 +234,7 @@ class _HomeWidgetState extends State<HomeWidget> {
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
subscription.cancel();
|
||||
_bloc.dispose();
|
||||
}
|
||||
|
||||
void _observeNetwork() {
|
||||
|
@ -23,14 +23,13 @@ class CoursesRowWidgetState extends State<CoursesRowWidget> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 32.0, left: 16, bottom: 8.0),
|
||||
child: Text('Courses', style: Theme.of(context).textTheme.headline3),
|
||||
),
|
||||
StreamBuilder<ApiResponse<CoursesResponse>>(
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 32.0, left: 16, bottom: 8.0),
|
||||
child: Text('Courses', style: Theme.of(context).textTheme.headline3),
|
||||
),
|
||||
SizeChangedLayoutNotifier(
|
||||
child: StreamBuilder<ApiResponse<CoursesResponse>>(
|
||||
stream: _bloc.coursesList.stream,
|
||||
initialData: ApiResponse.loading(),
|
||||
builder: (context, snapshot) {
|
||||
@ -48,8 +47,8 @@ class CoursesRowWidgetState extends State<CoursesRowWidget> {
|
||||
}
|
||||
return Container();
|
||||
}),
|
||||
],
|
||||
);
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _horizontalCoursesRow(
|
||||
|
@ -22,33 +22,37 @@ class DailyMessageWidgetState extends State<DailyMessageWidget> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<ApiResponse<DailyMessageResponse>>(
|
||||
stream: _bloc.coursesList.stream,
|
||||
builder: (context, snapshot) {
|
||||
var widget;
|
||||
return SizeChangedLayoutNotifier(
|
||||
child: StreamBuilder<ApiResponse<DailyMessageResponse>>(
|
||||
stream: _bloc.coursesList.stream,
|
||||
builder: (context, snapshot) {
|
||||
var widget;
|
||||
|
||||
if (!snapshot.hasData || snapshot.data == null || snapshot.data.body.body.isEmptyOrNull()) {
|
||||
return Container();
|
||||
}
|
||||
if (!snapshot.hasData ||
|
||||
snapshot.data == null ||
|
||||
snapshot.data.body.body.isEmptyOrNull()) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
switch (snapshot.data.status) {
|
||||
case Status.LOADING:
|
||||
widget = const CircularProgressIndicator();
|
||||
break;
|
||||
case Status.COMPLETED:
|
||||
widget = _getMessageWidget(snapshot, context);
|
||||
break;
|
||||
case Status.ERROR:
|
||||
widget = Container();
|
||||
break;
|
||||
}
|
||||
switch (snapshot.data.status) {
|
||||
case Status.LOADING:
|
||||
widget = const CircularProgressIndicator();
|
||||
break;
|
||||
case Status.COMPLETED:
|
||||
widget = _getMessageWidget(snapshot, context);
|
||||
break;
|
||||
case Status.ERROR:
|
||||
widget = Container();
|
||||
break;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16.0, right: 16.0, bottom: 32.0, top: 32.0),
|
||||
child: widget,
|
||||
);
|
||||
});
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16.0, right: 16.0, bottom: 32.0, top: 32.0),
|
||||
child: widget,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getMessageWidget(
|
||||
@ -75,8 +79,6 @@ class DailyMessageWidgetState extends State<DailyMessageWidget> {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
void _launchUrl(String text, String href, String title) {
|
||||
launchUrl(href);
|
||||
}
|
||||
|
@ -31,40 +31,42 @@ class SmallShortcutsRowWidgetState extends State<SmallShortcutsRowWidget> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<ApiResponse<ShortcutsResponse>>(
|
||||
stream: _bloc.shortcutList.stream,
|
||||
initialData: ApiResponse.loading(),
|
||||
builder: (context, snapshot) {
|
||||
switch (snapshot.data.status) {
|
||||
case Status.LOADING:
|
||||
return _getLoadingWidget();
|
||||
break;
|
||||
case Status.COMPLETED:
|
||||
return GridView.count(
|
||||
crossAxisCount: 2,
|
||||
padding:
|
||||
const EdgeInsets.only(left: 12.0, right: 12.0, top: 8.0),
|
||||
scrollDirection: Axis.vertical,
|
||||
childAspectRatio: 2.6,
|
||||
physics: NeverScrollableScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
children:
|
||||
List.generate(snapshot.data.body?.data?.length ?? 0, (index) {
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
color: MeditoColors.deepNight,
|
||||
child: SmallShortcutWidget(
|
||||
snapshot.data.body.data[index], widget.onTap),
|
||||
);
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case Status.ERROR:
|
||||
return Icon(Icons.error);
|
||||
break;
|
||||
}
|
||||
return Container();
|
||||
});
|
||||
return SizeChangedLayoutNotifier(
|
||||
child: StreamBuilder<ApiResponse<ShortcutsResponse>>(
|
||||
stream: _bloc.shortcutList.stream,
|
||||
initialData: ApiResponse.loading(),
|
||||
builder: (context, snapshot) {
|
||||
switch (snapshot.data.status) {
|
||||
case Status.LOADING:
|
||||
return _getLoadingWidget();
|
||||
break;
|
||||
case Status.COMPLETED:
|
||||
return GridView.count(
|
||||
crossAxisCount: 2,
|
||||
padding:
|
||||
const EdgeInsets.only(left: 12.0, right: 12.0, top: 8.0),
|
||||
scrollDirection: Axis.vertical,
|
||||
childAspectRatio: 2.6,
|
||||
physics: NeverScrollableScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
children: List.generate(snapshot.data.body?.data?.length ?? 0,
|
||||
(index) {
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
color: MeditoColors.deepNight,
|
||||
child: SmallShortcutWidget(
|
||||
snapshot.data.body.data[index], widget.onTap),
|
||||
);
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case Status.ERROR:
|
||||
return Icon(Icons.error);
|
||||
break;
|
||||
}
|
||||
return Container();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getLoadingWidget() => GridView.count(
|
||||
|
@ -18,15 +18,14 @@ class _StatsWidgetState extends State<StatsWidget> {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, bottom: 8.0),
|
||||
child: Text('Stats',
|
||||
style: Theme.of(context).textTheme.headline3),
|
||||
child: Text('Stats', style: Theme.of(context).textTheme.headline3),
|
||||
),
|
||||
SizedBox(
|
||||
height: 73,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 8.0, right: 8.0),
|
||||
itemBuilder: (context, i) => statsItem(context, i),
|
||||
itemCount: 5,
|
||||
itemCount: 4,
|
||||
scrollDirection: Axis.horizontal,
|
||||
shrinkWrap: true,
|
||||
),
|
||||
|
@ -15,6 +15,7 @@ along with Medito App. If not, see <https://www.gnu.org/licenses/>.*/
|
||||
|
||||
import 'package:Medito/utils/colors.dart';
|
||||
import 'package:Medito/utils/strings.dart';
|
||||
import 'package:Medito/utils/utils.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@ -52,9 +53,12 @@ class ErrorPacksWidget extends StatelessWidget {
|
||||
style: BorderStyle.solid,
|
||||
),
|
||||
),
|
||||
onPressed: () => onPressed(),
|
||||
onPressed: () {
|
||||
createSnackBar(RETRYING, context, color: MeditoColors.darkBGColor);
|
||||
onPressed();
|
||||
},
|
||||
child: Text(
|
||||
'Try again',
|
||||
TRY_AGAIN,
|
||||
style: Theme.of(context).textTheme.subtitle2,
|
||||
)),
|
||||
/* Container(width: 16),
|
||||
|
@ -71,10 +71,9 @@ class _ExpandedSectionState extends State<ExpandedSection> with SingleTickerProv
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizeTransition(
|
||||
axisAlignment: 1.0,
|
||||
sizeFactor: animation,
|
||||
child: widget.child
|
||||
return SizeChangedLayoutNotifier(
|
||||
child: SizeTransition(
|
||||
axisAlignment: 1.0, sizeFactor: animation, child: widget.child),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -41,10 +41,12 @@ dependencies:
|
||||
connectivity: ^3.0.3
|
||||
device_info: ^2.0.0
|
||||
dart_ipify: ^1.0.2
|
||||
equatable: ^2.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
mocktail: ^0.1.2
|
||||
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
|
@ -1,9 +1,23 @@
|
||||
import 'package:Medito/network/api_response.dart';
|
||||
import 'package:Medito/network/home/home_bloc.dart';
|
||||
import 'package:Medito/network/home/home_repo.dart';
|
||||
import 'package:Medito/network/home/menu_response.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockHomeRepo extends Mock implements HomeRepo {}
|
||||
|
||||
void main() {
|
||||
HomeRepo _mockHomeRepo;
|
||||
HomeBloc _bloc;
|
||||
|
||||
setUp(() {
|
||||
_mockHomeRepo = MockHomeRepo();
|
||||
_bloc = HomeBloc(repo: _mockHomeRepo);
|
||||
});
|
||||
|
||||
test('test greeting text', () async {
|
||||
var bloc = HomeBloc();
|
||||
var bloc = HomeBloc(repo: _mockHomeRepo);
|
||||
var now = DateTime(2021, 3, 1, 12, 30);
|
||||
var b = await bloc.getTitleText(now);
|
||||
expect(b, 'Good afternoon');
|
||||
@ -21,4 +35,43 @@ void main() {
|
||||
b = await bloc.getTitleText(now);
|
||||
expect(b, 'Good evening');
|
||||
});
|
||||
|
||||
test('menuList should broadcast ApiResponse data when repo responses data',
|
||||
() async {
|
||||
var expectedMenuResponse = _createMockMenuResponse();
|
||||
when(() => _mockHomeRepo.fetchMenu(false))
|
||||
.thenAnswer((realInvocation) async => expectedMenuResponse);
|
||||
var bloc = HomeBloc(repo: _mockHomeRepo);
|
||||
var firstResponse = bloc.menuList.stream.first;
|
||||
|
||||
await bloc.fetchMenu();
|
||||
expect(await firstResponse, ApiResponse.completed(expectedMenuResponse));
|
||||
});
|
||||
|
||||
test('menuList should broadcast ApiResponse error when repo throws',
|
||||
() async {
|
||||
when(() => _mockHomeRepo.fetchMenu(false)).thenThrow(Error());
|
||||
var firstResponse = _bloc.menuList.stream.first;
|
||||
|
||||
await _bloc.fetchMenu();
|
||||
var apiResponse = (await firstResponse);
|
||||
expect(apiResponse.status, Status.ERROR);
|
||||
expect(apiResponse.body, null);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
_bloc?.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
MenuResponse _createMockMenuResponse() {
|
||||
var item = {
|
||||
'item_label': 'mock_item_label',
|
||||
'item_type': 'mock_item_type',
|
||||
'item_path': 'mock_item_path'
|
||||
};
|
||||
var mockData = {
|
||||
'data': [item]
|
||||
};
|
||||
return MenuResponse.fromJson(mockData);
|
||||
}
|
||||
|
73
test/widgets/packs/error_widget_test.dart
Normal file
73
test/widgets/packs/error_widget_test.dart
Normal file
@ -0,0 +1,73 @@
|
||||
import 'package:Medito/utils/strings.dart';
|
||||
import 'package:Medito/widgets/packs/error_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('errorWidget should show loading_error text',
|
||||
(WidgetTester tester) async {
|
||||
var testingWidget = _wrapWidgetForTesting(ErrorPacksWidget(
|
||||
onPressed: () {},
|
||||
));
|
||||
await tester.pumpWidget(testingWidget);
|
||||
|
||||
expect(find.text(LOADING_ERROR), findsOneWidget);
|
||||
expect(find.text(TRY_AGAIN), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('errorWidget should show snackbar when tapping the button',
|
||||
(WidgetTester tester) async {
|
||||
var testingWidget = _wrapWidgetForTesting(ErrorPacksWidget(
|
||||
onPressed: () {},
|
||||
));
|
||||
await tester.pumpWidget(testingWidget);
|
||||
|
||||
var button = find.byType(OutlinedButton);
|
||||
expect(button, findsOneWidget);
|
||||
|
||||
// Make sure the retrying text is not showing until tapping the button
|
||||
expect(find.text(RETRYING), findsNothing);
|
||||
|
||||
await tester.tap(button);
|
||||
// fast-forward the button animation
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(RETRYING), findsOneWidget);
|
||||
|
||||
// fast-forward 6001 ms for snackbar disappear, its duration is 6000ms
|
||||
await tester.pumpAndSettle(Duration(milliseconds: 6001));
|
||||
expect(find.text(RETRYING), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'errorWidget should call onPressed callback when pressing the button',
|
||||
(WidgetTester tester) async {
|
||||
var counterCallback = 0;
|
||||
var onPressed = () => counterCallback++;
|
||||
var testingWidget = _wrapWidgetForTesting(ErrorPacksWidget(
|
||||
onPressed: onPressed,
|
||||
));
|
||||
await tester.pumpWidget(testingWidget);
|
||||
|
||||
var button = find.byType(OutlinedButton);
|
||||
await tester.tap(button);
|
||||
// fast-forward the button animation
|
||||
await tester.pumpAndSettle();
|
||||
expect(counterCallback, 1);
|
||||
});
|
||||
}
|
||||
|
||||
// Wrapping the widget with MaterialApp because it needs to know the text direction (LTR or RTL)
|
||||
// and ScaffoldMessenger for showing the snackbar.
|
||||
// This is only needed when testing individual widget.
|
||||
// The app works without it because it is wrapped with MaterialApp at the top level.
|
||||
Widget _wrapWidgetForTesting(Widget _child) {
|
||||
return MaterialApp(
|
||||
title: 'TestAppWrapper',
|
||||
home: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('TestAppBar'),
|
||||
),
|
||||
body: _child,
|
||||
),
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue
Block a user