diff --git a/app/integration_test/search_test.dart b/app/integration_test/search_test.dart index 9f0cffc71..ea986e4cc 100644 --- a/app/integration_test/search_test.dart +++ b/app/integration_test/search_test.dart @@ -6,14 +6,14 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:mocktail/mocktail.dart'; -class MockSearchCubit extends MockCubit implements SearchCubit { +class MockDrugListCubit extends MockCubit implements DrugListCubit { @override FilterState get filter => FilterState.initial(); } void main() { final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - final mockSearchCubit = MockSearchCubit(); + final mockDrugListCubit = MockDrugListCubit(); binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.onlyPumps; final loadedDrugs = [ @@ -50,12 +50,12 @@ void main() { ]; group('integration test for the search page', () { testWidgets('test search page in loading state', (tester) async { - when(() => mockSearchCubit.state).thenReturn(SearchState.loading()); + when(() => mockDrugListCubit.state).thenReturn(DrugListState.loading()); await tester.pumpWidget(BlocProvider.value( - value: mockSearchCubit, + value: mockDrugListCubit, child: MaterialApp( debugShowCheckedModeBanner: false, - home: SearchPage(cubit: mockSearchCubit), + home: SearchPage(cubit: mockDrugListCubit), localizationsDelegates: [ AppLocalizations.delegate, GlobalMaterialLocalizations.delegate, @@ -70,14 +70,14 @@ void main() { }); testWidgets('test search page in loaded state', (tester) async { - when(() => mockSearchCubit.state) - .thenReturn(SearchState.loaded(loadedDrugs, FilterState.initial())); + when(() => mockDrugListCubit.state) + .thenReturn(DrugListState.loaded(loadedDrugs, FilterState.initial())); await tester.pumpWidget(BlocProvider.value( - value: mockSearchCubit, + value: mockDrugListCubit, child: MaterialApp( debugShowCheckedModeBanner: false, - home: SearchPage(cubit: mockSearchCubit), + home: SearchPage(cubit: mockDrugListCubit), localizationsDelegates: [ AppLocalizations.delegate, GlobalMaterialLocalizations.delegate, diff --git a/app/lib/common/routing/router.dart b/app/lib/common/routing/router.dart index 1687de88e..93bb4db63 100644 --- a/app/lib/common/routing/router.dart +++ b/app/lib/common/routing/router.dart @@ -1,6 +1,4 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/material.dart'; - +import '../../common/module.dart'; import '../../faq/module.dart'; import '../../login/module.dart'; import '../../report/module.dart'; diff --git a/app/lib/common/utilities/genome_data.dart b/app/lib/common/utilities/genome_data.dart index 78b0293d9..6697247bf 100644 --- a/app/lib/common/utilities/genome_data.dart +++ b/app/lib/common/utilities/genome_data.dart @@ -68,6 +68,9 @@ Future fetchAndSaveLookups() async { matchingLookups[diplotype.gene] = lookup; } + // uncomment to make user have CYP2D6 lookupkey 0.0 + // matchingLookups['CYP2D6'] = lookupsHashMap['CYP2D6__*100/*100']!; + UserData.instance.lookups = matchingLookups; await UserData.save(); diff --git a/app/lib/common/widgets/drug_list/builder.dart b/app/lib/common/widgets/drug_list/builder.dart new file mode 100644 index 000000000..52102340e --- /dev/null +++ b/app/lib/common/widgets/drug_list/builder.dart @@ -0,0 +1,100 @@ +import '../../module.dart'; + +List buildDrugList( + BuildContext context, + DrugListState state, { + String? noDrugsMessage, +}) => + state.when( + initial: () => [Container()], + error: () => [errorIndicator(context.l10n.err_generic)], + loaded: (drugs, filter) => _buildDrugCards( + context, + drugs, + filter, + noDrugsMessage: noDrugsMessage, + ), + loading: () => [loadingIndicator()], + ); + +List _buildDrugCards( + BuildContext context, + List drugs, + FilterState filter, { + String? noDrugsMessage, +}) { + final filteredDrugs = filter.filter(drugs); + if (filteredDrugs.isEmpty && noDrugsMessage != null) { + return [errorIndicator(noDrugsMessage)]; + } + return [ + SizedBox(height: 8), + ...filteredDrugs.map((drug) => Column(children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: DrugCard( + onTap: () => context.router + .push(DrugRoute(drug: drug)) + .then((_) => context.read().search()), + drug: drug)), + SizedBox(height: 12) + ])) + ]; +} + +class DrugCard extends StatelessWidget { + const DrugCard({ + required this.onTap, + required this.drug, + }); + + final VoidCallback onTap; + final Drug drug; + + @override + Widget build(BuildContext context) { + final warningLevel = drug.userGuideline()?.annotations.warningLevel; + + return RoundedCard( + onTap: onTap, + padding: EdgeInsets.all(8), + radius: 16, + color: warningLevel?.color ?? PharMeTheme.onSurfaceColor, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Icon(warningLevel?.icon ?? Icons.help_outline_rounded), + SizedBox(width: 4), + Text( + drug.name.capitalize(), + style: PharMeTheme.textTheme.titleMedium! + .copyWith(fontWeight: FontWeight.bold), + ), + ]), + SizedBox(height: 4), + if (drug.annotations.brandNames.isNotEmpty) ...[ + SizedBox(width: 4), + Text( + '(${drug.annotations.brandNames.join(', ')})', + style: PharMeTheme.textTheme.titleMedium, + ), + ], + SizedBox(height: 8), + Text( + drug.annotations.drugclass, + style: PharMeTheme.textTheme.titleSmall, + ), + ], + ), + ), + Icon(Icons.chevron_right_rounded), + ], + ), + ); + } +} diff --git a/app/lib/search/pages/cubit.dart b/app/lib/common/widgets/drug_list/cubit.dart similarity index 54% rename from app/lib/search/pages/cubit.dart rename to app/lib/common/widgets/drug_list/cubit.dart index 365c56781..f869d0edb 100644 --- a/app/lib/search/pages/cubit.dart +++ b/app/lib/common/widgets/drug_list/cubit.dart @@ -2,14 +2,16 @@ import 'dart:async'; import 'package:freezed_annotation/freezed_annotation.dart'; -import '../../common/models/drug/cached_drugs.dart'; -import '../../common/module.dart'; +import '../../models/drug/cached_drugs.dart'; +import '../../module.dart'; part 'cubit.freezed.dart'; -class SearchCubit extends Cubit { - SearchCubit() : super(SearchState.initial()) { - loadDrugs(); +class DrugListCubit extends Cubit { + DrugListCubit({ + FilterState? initialFilter, + }) : super(DrugListState.initial()) { + loadDrugs(filter: initialFilter); } Timer? searchTimeout; @@ -24,7 +26,7 @@ class SearchCubit extends Cubit { state.whenOrNull( initial: loadDrugs, loaded: (allDrugs, filter) => emit( - SearchState.loaded( + DrugListState.loaded( allDrugs, FilterState.from( filter, @@ -39,27 +41,32 @@ class SearchCubit extends Cubit { } Future loadDrugs({ + FilterState? filter, bool updateIfNull = true, bool useCache = true, }) async { + filter = filter ?? + state.whenOrNull(loaded: (_, filter) => filter) ?? + FilterState.initial(); + if (useCache) { final drugs = CachedDrugs.instance.drugs; if (drugs != null) { - emit(SearchState.loaded(drugs, FilterState.initial())); + emit(DrugListState.loaded(drugs, filter)); return; } if (!updateIfNull) { - emit(SearchState.error()); + emit(DrugListState.error()); return; } } - emit(SearchState.loading()); + emit(DrugListState.loading()); try { await updateCachedDrugs(); - await loadDrugs(updateIfNull: false); + await loadDrugs(updateIfNull: false, filter: filter); } catch (error) { - emit(SearchState.error()); + emit(DrugListState.error()); } } @@ -71,18 +78,25 @@ class FilterState { required this.query, required this.showInactive, required this.showWarningLevel, + required this.gene, }); FilterState.initial() - : this(query: '', showInactive: true, showWarningLevel: { - for (var level in WarningLevel.values) level: true - }); + : this( + query: '', + showInactive: true, + showWarningLevel: { + for (var level in WarningLevel.values) level: true + }, + gene: '', + ); FilterState.from( FilterState other, { String? query, bool? showInactive, Map? showWarningLevel, + String? gene, }) : this( query: query ?? other.query, showInactive: showInactive ?? other.showInactive, @@ -90,29 +104,45 @@ class FilterState { for (var level in WarningLevel.values) level: showWarningLevel?[level] ?? other.showWarningLevel[level]! }, + gene: gene ?? other.gene, + ); + + FilterState.forGene(String gene) + : this( + query: '', + showInactive: true, + showWarningLevel: { + for (var level in WarningLevel.values) + level: level != WarningLevel.none + }, + gene: gene, ); final String query; final bool showInactive; final Map showWarningLevel; + final String gene; bool isAccepted(Drug drug) { - final warningLevel = drug.userGuideline()?.annotations.warningLevel; + final guideline = drug.userGuideline(); + final warningLevel = + guideline?.annotations.warningLevel ?? WarningLevel.none; return drug.matches(query: query) && (drug.isActive() || showInactive) && - (showWarningLevel[warningLevel] ?? true); + (showWarningLevel[warningLevel] ?? true) && + (gene.isBlank || (guideline?.lookupkey.keys.contains(gene) ?? false)); } List filter(List drugs) => drugs.filter(isAccepted).toList(); } @freezed -class SearchState with _$SearchState { - const factory SearchState.initial() = _InitialState; - const factory SearchState.loading() = _LoadingState; - const factory SearchState.loaded( +class DrugListState with _$DrugListState { + const factory DrugListState.initial() = _InitialState; + const factory DrugListState.loading() = _LoadingState; + const factory DrugListState.loaded( List allDrugs, FilterState filter, ) = _LoadedState; - const factory SearchState.error() = _ErrorState; + const factory DrugListState.error() = _ErrorState; } diff --git a/app/lib/common/widgets/module.dart b/app/lib/common/widgets/module.dart index 75550056b..0757013e4 100644 --- a/app/lib/common/widgets/module.dart +++ b/app/lib/common/widgets/module.dart @@ -1,5 +1,7 @@ export 'app.dart'; export 'context_menu.dart'; +export 'drug_list/builder.dart'; +export 'drug_list/cubit.dart'; export 'headings.dart'; export 'indicators.dart'; export 'page_scaffold.dart'; diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 6b3210b24..68c5ab4d3 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -101,6 +101,9 @@ "gene_page_genotype_tooltip": "The genotype describes your personal variant of this gene.", "gene_page_phenotype": "Phenotype", "gene_page_phenotype_tooltip": "The phenotype describes the effect that your personal variant of this gene has on the enzyme it encodes.", + "gene_page_affected_drugs": "Affected drugs", + "gene_page_affected_drugs_tooltip": "The drugs listed here are affected by your variant of this gene.", + "gene_page_no_affected_drugs": "Your variant of this gene has no known effect on any drug.", "nav_report": "Report", "tab_report": "Gene report", diff --git a/app/lib/report/module.dart b/app/lib/report/module.dart index 857dbdf9f..25a69296c 100644 --- a/app/lib/report/module.dart +++ b/app/lib/report/module.dart @@ -1,4 +1,5 @@ import '../common/module.dart'; +import '../search/module.dart'; import 'pages/gene.dart'; import 'pages/report.dart'; @@ -12,5 +13,6 @@ const reportRoutes = AutoRoute( children: [ AutoRoute(path: '', page: ReportPage), AutoRoute(page: GenePage), + AutoRoute(page: DrugPage) ], ); diff --git a/app/lib/report/pages/gene.dart b/app/lib/report/pages/gene.dart index 77c2a34df..825dc0ce8 100644 --- a/app/lib/report/pages/gene.dart +++ b/app/lib/report/pages/gene.dart @@ -2,43 +2,62 @@ import '../../common/module.dart'; import '../../common/pages/drug/widgets/sub_header.dart'; import '../../common/pages/drug/widgets/tooltip_icon.dart'; -class GenePage extends StatelessWidget { - const GenePage(this.phenotype); +class GenePage extends HookWidget { + GenePage(this.phenotype) + : cubit = DrugListCubit( + initialFilter: FilterState.forGene(phenotype.geneSymbol), + ); + final CpicPhenotype phenotype; + final DrugListCubit cubit; @override Widget build(BuildContext context) { - return pageScaffold( - title: context.l10n.gene_page_headline(phenotype.geneSymbol), - body: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: 12, vertical: 16), - child: - Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - SubHeader( - context.l10n.gene_page_your_variant(phenotype.geneSymbol), - tooltip: - context.l10n.gene_page_name_tooltip(phenotype.geneSymbol), - ), - SizedBox(height: 12), - RoundedCard( - child: Table( - columnWidths: Map.from({ - 0: IntrinsicColumnWidth(), - 1: IntrinsicColumnWidth(flex: 1), - }), - children: [ - _buildRow( - context.l10n.gene_page_genotype, phenotype.genotype, - tooltip: context.l10n.gene_page_genotype_tooltip), - _buildRow(context.l10n.gene_page_phenotype, - UserData.phenotypeFor(phenotype.geneSymbol)!, - tooltip: context.l10n.gene_page_phenotype_tooltip), - ]), + return BlocProvider( + create: (context) => cubit, + child: BlocBuilder( + builder: (context, state) => pageScaffold( + title: context.l10n.gene_page_headline(phenotype.geneSymbol), + body: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SubHeader( + context.l10n.gene_page_your_variant(phenotype.geneSymbol), + tooltip: context.l10n + .gene_page_name_tooltip(phenotype.geneSymbol), + ), + SizedBox(height: 12), + RoundedCard( + child: Table( + columnWidths: Map.from({ + 0: IntrinsicColumnWidth(), + 1: IntrinsicColumnWidth(flex: 1), + }), + children: [ + _buildRow( + context.l10n.gene_page_genotype, phenotype.genotype, + tooltip: context.l10n.gene_page_genotype_tooltip), + _buildRow(context.l10n.gene_page_phenotype, + UserData.phenotypeFor(phenotype.geneSymbol)!, + tooltip: context.l10n.gene_page_phenotype_tooltip), + ], + ), + ), + SizedBox(height: 12), + SubHeader(context.l10n.gene_page_affected_drugs, + tooltip: context.l10n.gene_page_affected_drugs_tooltip), + ...buildDrugList(context, state, + noDrugsMessage: context.l10n.gene_page_no_affected_drugs) + ], ), - ]), - ), - ]); + ), + ], + ), + ), + ); } TableRow _buildRow(String key, String value, {String? tooltip}) => diff --git a/app/lib/search/module.dart b/app/lib/search/module.dart index 19267a7f1..09f1e070c 100644 --- a/app/lib/search/module.dart +++ b/app/lib/search/module.dart @@ -6,7 +6,6 @@ import 'pages/search.dart'; export '../common/models/module.dart'; export '../common/pages/drug/cubit.dart'; export '../common/pages/drug/drug.dart'; -export 'pages/cubit.dart'; export 'pages/search.dart'; const searchRoutes = AutoRoute( diff --git a/app/lib/search/pages/search.dart b/app/lib/search/pages/search.dart index ac29c43b8..7813f07e7 100644 --- a/app/lib/search/pages/search.dart +++ b/app/lib/search/pages/search.dart @@ -2,16 +2,15 @@ import 'package:flutter/cupertino.dart'; import '../../../common/module.dart'; import '../../common/pages/drug/widgets/tooltip_icon.dart'; -import 'cubit.dart'; class SearchPage extends HookWidget { SearchPage({ Key? key, - @visibleForTesting SearchCubit? cubit, - }) : cubit = cubit ?? SearchCubit(), + @visibleForTesting DrugListCubit? cubit, + }) : cubit = cubit ?? DrugListCubit(), super(key: key); - final SearchCubit cubit; + final DrugListCubit cubit; @override Widget build(BuildContext context) { @@ -24,33 +23,30 @@ class SearchPage extends HookWidget { return BlocProvider( create: (context) => cubit, - child: BlocBuilder(builder: (context, state) { + child: BlocBuilder( + builder: (context, state) { return pageScaffold( - title: context.l10n.tab_drugs, - barBottom: Row(children: [ - Expanded( - child: CupertinoSearchTextField( - controller: searchController, - onChanged: (value) { - context.read().search(query: value); - }, - )), - SizedBox(width: 12), - TooltipIcon(context.l10n.search_page_tooltip_search), - buildFilter(context), - ]), - body: state.when( - initial: () => [Container()], - error: () => [errorIndicator(context.l10n.err_generic)], - loaded: (drugs, filter) => - _buildDrugsList(context, drugs, filter), - loading: () => [loadingIndicator()], - )); + title: context.l10n.tab_drugs, + barBottom: Row(children: [ + Expanded( + child: CupertinoSearchTextField( + controller: searchController, + onChanged: (value) { + context.read().search(query: value); + }, + )), + SizedBox(width: 12), + TooltipIcon(context.l10n.search_page_tooltip_search), + buildFilter(context), + ]), + body: buildDrugList(context, state, + noDrugsMessage: context.l10n.err_no_drugs), + ); })); } Widget buildFilter(BuildContext context) { - final cubit = context.read(); + final cubit = context.read(); final filter = cubit.filter; return ContextMenu( items: [ @@ -72,82 +68,4 @@ class SearchPage extends HookWidget { padding: EdgeInsets.all(8), child: Icon(Icons.filter_list_rounded)), ); } - - List _buildDrugsList( - BuildContext context, List drugs, FilterState filter) { - final filteredDrugs = filter.filter(drugs); - if (filteredDrugs.isEmpty) { - return [errorIndicator(context.l10n.err_no_drugs)]; - } - return [ - SizedBox(height: 8), - ...filteredDrugs.map((drug) => Column(children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: 8), - child: DrugCard( - onTap: () => context.router - .push(DrugRoute(drug: drug)) - .then((_) => context.read().search()), - drug: drug)), - SizedBox(height: 12) - ])) - ]; - } -} - -class DrugCard extends StatelessWidget { - const DrugCard({ - required this.onTap, - required this.drug, - }); - - final VoidCallback onTap; - final Drug drug; - - @override - Widget build(BuildContext context) { - final warningLevel = drug.userGuideline()?.annotations.warningLevel; - - return RoundedCard( - onTap: onTap, - padding: EdgeInsets.all(8), - radius: 16, - color: warningLevel?.color ?? PharMeTheme.onSurfaceColor, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - Icon(warningLevel?.icon ?? Icons.help_outline_rounded), - SizedBox(width: 4), - Text( - drug.name.capitalize(), - style: PharMeTheme.textTheme.titleMedium! - .copyWith(fontWeight: FontWeight.bold), - ), - ]), - SizedBox(height: 4), - if (drug.annotations.brandNames.isNotEmpty) ...[ - SizedBox(width: 4), - Text( - '(${drug.annotations.brandNames.join(', ')})', - style: PharMeTheme.textTheme.titleMedium, - ), - ], - SizedBox(height: 8), - Text( - drug.annotations.drugclass, - style: PharMeTheme.textTheme.titleSmall, - ), - ], - ), - ), - Icon(Icons.chevron_right_rounded), - ], - ), - ); - } }