Merge branch 'develop' of https://github.com/meditohq/medito-app into develop

This commit is contained in:
Michael Speed 2021-05-15 18:40:12 +02:00
commit e702cee519
15 changed files with 261 additions and 102 deletions

2
.gitignore vendored
View File

@ -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/

View File

@ -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 }

View File

@ -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();
}
}

View File

@ -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];
}

View File

@ -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';

View File

@ -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() {

View File

@ -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(

View File

@ -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);
}

View File

@ -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(

View File

@ -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,
),

View File

@ -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),

View File

@ -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),
);
}
}
}

View File

@ -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

View File

@ -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);
}

View 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,
),
);
}