diff --git a/.github/workflows/app.yml b/.github/workflows/app.yml index c8466b860..1d55943d6 100644 --- a/.github/workflows/app.yml +++ b/.github/workflows/app.yml @@ -13,7 +13,7 @@ defaults: env: JAVA_VERSION: 12.x FLUTTER_CHANNEL: stable - FLUTTER_VERSION: 3.0.3 + FLUTTER_VERSION: 3.3.4 jobs: lint: @@ -24,7 +24,7 @@ jobs: - uses: actions/setup-java@v1 with: java-version: ${{ env.JAVA_VERSION }} - - uses: subosito/flutter-action@v1 + - uses: subosito/flutter-action@v2 with: channel: ${{ env.FLUTTER_CHANNEL }} flutter-version: ${{ env.FLUTTER_VERSION }} diff --git a/anni/src/components/bricks/BrickForm.tsx b/anni/src/components/bricks/BrickForm.tsx index bd01b92ab..e431293fc 100644 --- a/anni/src/components/bricks/BrickForm.tsx +++ b/anni/src/components/bricks/BrickForm.tsx @@ -85,6 +85,7 @@ const BrickForm = ({ usage, brick }: Props) => { setMessage(`Failed to ${id ? 'update' : 'add new'} Brick.`); } }; + // eslint-disable-next-line @typescript-eslint/no-unused-vars const deleteBrick = async () => { try { await axios.delete(`/api/bricks/${id}`); @@ -144,7 +145,9 @@ const BrickForm = ({ usage, brick }: Props) => { Cancel
- {id && ( + {/* + // Disabled for #378 until proper handling of deletion + id && ( { > Delete Brick - )} + )*/} mockDrugsCubit.state) - .thenReturn(DrugState.loaded(testDrug, isStarred: false)); + .thenReturn(DrugState.loaded(testDrug, isActive: false)); late BuildContext context; @@ -180,7 +188,7 @@ void main() { testWidgets('test loaded page without guidelines', (tester) async { when(() => mockDrugsCubit.state).thenReturn( - DrugState.loaded(testDrugWithoutGuidelines, isStarred: true)); + DrugState.loaded(testDrugWithoutGuidelines, isActive: true)); await tester.pumpWidget( MaterialApp( diff --git a/app/integration_test/search_test.dart b/app/integration_test/search_test.dart index fbfb9ece6..9f0cffc71 100644 --- a/app/integration_test/search_test.dart +++ b/app/integration_test/search_test.dart @@ -8,7 +8,7 @@ import 'package:mocktail/mocktail.dart'; class MockSearchCubit extends MockCubit implements SearchCubit { @override - bool get filterStarred => false; + FilterState get filter => FilterState.initial(); } void main() { @@ -71,7 +71,7 @@ void main() { testWidgets('test search page in loaded state', (tester) async { when(() => mockSearchCubit.state) - .thenReturn(SearchState.loaded(loadedDrugs, loadedDrugs)); + .thenReturn(SearchState.loaded(loadedDrugs, FilterState.initial())); await tester.pumpWidget(BlocProvider.value( value: mockSearchCubit, diff --git a/app/integration_test/settings_test.dart b/app/integration_test/settings_test.dart index 8a43fa50e..8954b95e7 100644 --- a/app/integration_test/settings_test.dart +++ b/app/integration_test/settings_test.dart @@ -44,7 +44,7 @@ void main() { ); // close dialog - await tester.tap(find.text(context.l10n.settings_page_cancel)); + await tester.tap(find.text(context.l10n.action_cancel)); await tester.pumpAndSettle(); // test onboarding button diff --git a/app/lib/common/models/drug/drug.dart b/app/lib/common/models/drug/drug.dart index 8350e05ab..8c949a675 100644 --- a/app/lib/common/models/drug/drug.dart +++ b/app/lib/common/models/drug/drug.dart @@ -66,9 +66,9 @@ class DrugAnnotations { List brandNames; } -extension DrugIsStarred on Drug { - bool isStarred() { - return UserData.instance.starredDrugIds?.contains(id) ?? false; +extension DrugIsActive on Drug { + bool isActive() { + return UserData.instance.activeDrugNames?.contains(name) ?? false; } } @@ -80,52 +80,20 @@ extension DrugMatchesQuery on Drug { } } -/// Removes the guidelines that are not relevant to the user -extension DrugWithUserGuidelines on Drug { - Drug filterUserGuidelines() { - final matchingGuidelines = guidelines.where((guideline) { - // Guideline matches if all user has any of the gene results for all gene - // symbols - return guideline.lookupkey.all((geneSymbol, geneResults) => - (UserData.instance.lookups?.containsKey(geneSymbol) ?? false) && - geneResults - .contains(UserData.instance.lookups?[geneSymbol]?.lookupkey)); - }); - - return Drug( - id: id, - version: version, - name: name, - rxNorm: rxNorm, - annotations: annotations, - guidelines: matchingGuidelines.toList(), - ); - } -} - -/// Removes the guidelines that are not relevant to the user -extension DrugsWithUserGuidelines on List { - List filterUserGuidelines() { - return map((drug) => drug.filterUserGuidelines()).toList(); - } +/// Gets the User's matching guideline +extension DrugWithUserGuideline on Drug { + Guideline? userGuideline() => guidelines.firstOrNullWhere( + (guideline) => guideline.lookupkey.all((geneSymbol, geneResults) => + geneResults.contains(UserData.lookupFor(geneSymbol))), + ); } /// Filters for drugs with non-OK warning level extension CriticalDrugs on List { List filterCritical() { return filter((drug) { - final warningLevel = drug.highestWarningLevel(); + final warningLevel = drug.userGuideline()?.annotations.warningLevel; return warningLevel != null && warningLevel != WarningLevel.green; }).toList(); } } - -/// Gets most severe warning level -extension DrugWarningLevel on Drug { - WarningLevel? highestWarningLevel() { - final filtered = filterUserGuidelines(); - return filtered.guidelines - .map((guideline) => guideline.annotations.warningLevel) - .maxBy((level) => level.severity); - } -} diff --git a/app/lib/common/models/drug/drug_inhibitors.dart b/app/lib/common/models/drug/drug_inhibitors.dart new file mode 100644 index 000000000..2bf92f450 --- /dev/null +++ b/app/lib/common/models/drug/drug_inhibitors.dart @@ -0,0 +1,24 @@ +// Everything has to match literally. The final value is not a phenotype but +// the CPIC lookupkey value. If a user has multiple of the given drugs active, +// the topmost one will be used, i.e. the inhibitors should go from most to +// least severe. + +// structure: gene symbol -> drug name -> overwriting lookupkey + +const Map> drugInhibitors = { + 'CYP2D6': { + // 0.0 is a lookupkey for a type of poor metabolizer + 'bupropion': '0.0', + 'fluoxetine': '0.0', + 'paroxetine': '0.0', + 'quinidine': '0.0', + 'terbinafine': '0.0', + // 1.0 is a lookupkey for a type of poor (but less poor than 0.0) + // metabolizer + 'abiraterone': '1.0', + 'cinacalcet': '1.0', + 'duloxetine': '1.0', + 'lorcaserin': '1.0', + 'mirabegron': '1.0', + } +}; diff --git a/app/lib/common/models/drug/warning_level.dart b/app/lib/common/models/drug/warning_level.dart index 1b7726ec0..600dfe6ad 100644 --- a/app/lib/common/models/drug/warning_level.dart +++ b/app/lib/common/models/drug/warning_level.dart @@ -10,7 +10,9 @@ enum WarningLevel { @HiveField(1) yellow, @HiveField(2) - green + green, + @HiveField(3) + none, } extension WarningLevelIcon on WarningLevel { diff --git a/app/lib/common/models/module.dart b/app/lib/common/models/module.dart index 66ec1fd4c..30f7b4735 100644 --- a/app/lib/common/models/module.dart +++ b/app/lib/common/models/module.dart @@ -1,5 +1,6 @@ export 'anni_response.dart'; export 'drug/drug.dart'; +export 'drug/drug_inhibitors.dart'; export 'drug/guideline.dart'; export 'drug/warning_level.dart'; export 'metadata.dart'; diff --git a/app/lib/common/models/userdata/userdata.dart b/app/lib/common/models/userdata/userdata.dart index ebdfed26d..b1d0158e0 100644 --- a/app/lib/common/models/userdata/userdata.dart +++ b/app/lib/common/models/userdata/userdata.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:hive/hive.dart'; import '../../../search/module.dart'; @@ -30,13 +31,33 @@ class UserData { } @HiveField(0) - List? diplotypes; + Map? diplotypes; + static String? phenotypeFor(String gene) => + UserData.instance.diplotypes?[gene]?.phenotype; @HiveField(1) Map? lookups; + static MapEntry? overwrittenLookup(String gene) { + final inhibitors = drugInhibitors[gene]; + if (inhibitors == null) return null; + final lookup = inhibitors.entries.firstWhereOrNull((entry) => + UserData.instance.activeDrugNames?.contains(entry.key) ?? false); + if (lookup == null) return null; + return lookup; + } + + static String? lookupFor(String gene) { + final overwrittenLookup = UserData.overwrittenLookup(gene); + if (overwrittenLookup != null) { + return overwrittenLookup.value; + } + return UserData.instance.lookups?[gene]?.lookupkey; + } + + // hive can't deal with sets so we have to use a list :( @HiveField(2) - List? starredDrugIds; + List? activeDrugNames; } /// Initializes the user's data by registering all necessary adapters and diff --git a/app/lib/common/pages/drug/cubit.dart b/app/lib/common/pages/drug/cubit.dart index b9b53fb94..42a33e61b 100644 --- a/app/lib/common/pages/drug/cubit.dart +++ b/app/lib/common/pages/drug/cubit.dart @@ -6,24 +6,26 @@ part 'cubit.freezed.dart'; class DrugCubit extends Cubit { DrugCubit(this._drug) : super(DrugState.initial()) { - emit(DrugState.loaded(_drug, isStarred: _drug.isStarred())); + emit(DrugState.loaded(_drug, isActive: _drug.isActive())); } final Drug _drug; - Future toggleStarred() async { + // ignore: avoid_positional_boolean_parameters + Future setActivity(bool? value) async { + if (value == null) return; final drug = state.whenOrNull(loaded: (drug, _) => drug); if (drug == null) return; - final stars = UserData.instance.starredDrugIds ?? []; - if (drug.isStarred()) { - UserData.instance.starredDrugIds = - stars.filter((element) => element != _drug.id).toList(); - } else { - UserData.instance.starredDrugIds = stars + [_drug.id]; + final active = (UserData.instance.activeDrugNames ?? []) + .filter((name) => name != _drug.name) + .toList(); + if (value) { + active.add(_drug.name); } + UserData.instance.activeDrugNames = active; await UserData.save(); - emit(DrugState.loaded(drug, isStarred: drug.isStarred())); + emit(DrugState.loaded(drug, isActive: value)); } } @@ -31,7 +33,7 @@ class DrugCubit extends Cubit { class DrugState with _$DrugState { const factory DrugState.initial() = _InitialState; const factory DrugState.loading() = _LoadingState; - const factory DrugState.loaded(Drug drug, {required bool isStarred}) = + const factory DrugState.loaded(Drug drug, {required bool isActive}) = _LoadedState; const factory DrugState.error() = _ErrorState; } diff --git a/app/lib/common/pages/drug/drug.dart b/app/lib/common/pages/drug/drug.dart index 2da19ba3d..d0a15b808 100644 --- a/app/lib/common/pages/drug/drug.dart +++ b/app/lib/common/pages/drug/drug.dart @@ -28,13 +28,9 @@ class DrugPage extends StatelessWidget { body: [errorIndicator(context.l10n.err_generic)]), loading: () => pageScaffold(title: drugName, body: [loadingIndicator()]), - loaded: (drug, isStarred) => pageScaffold( + loaded: (drug, isActive) => pageScaffold( title: drugName, actions: [ - IconButton( - onPressed: () => context.read().toggleStarred(), - icon: PharMeTheme.starIcon(isStarred: isStarred), - ), IconButton( onPressed: () => sharePdf(drug), icon: Icon( @@ -44,7 +40,7 @@ class DrugPage extends StatelessWidget { ) ], body: [ - _buildDrugsPage(drug, isStarred: isStarred, context: context) + _buildDrugsPage(drug, isActive: isActive, context: context) ], ), ); @@ -55,28 +51,33 @@ class DrugPage extends StatelessWidget { Widget _buildDrugsPage( Drug drug, { - required bool isStarred, + required bool isActive, required BuildContext context, }) { + final userGuideline = drug.userGuideline(); return Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SubHeader(context.l10n.drugs_page_header_druginfo), + SubHeader(context.l10n.drugs_page_header_drug), SizedBox(height: 12), - DrugAnnotationCard(drug), + DrugAnnotationCard( + drug, + isActive: isActive, + setActivity: context.read().setActivity, + ), SizedBox(height: 20), SubHeader( context.l10n.drugs_page_header_guideline, tooltip: context.l10n.drugs_page_tooltip_guideline, ), SizedBox(height: 12), - ...(drug.guidelines.isNotEmpty) + ...(userGuideline != null) ? [ Disclaimer(), SizedBox(height: 12), - GuidelineAnnotationCard(drug.guidelines[0]) + GuidelineAnnotationCard(userGuideline) ] : [Text(context.l10n.drugs_page_no_guidelines_for_phenotype)] ], diff --git a/app/lib/common/pages/drug/widgets/annotation_cards/drug.dart b/app/lib/common/pages/drug/widgets/annotation_cards/drug.dart index 91f71d106..13b702dde 100644 --- a/app/lib/common/pages/drug/widgets/annotation_cards/drug.dart +++ b/app/lib/common/pages/drug/widgets/annotation_cards/drug.dart @@ -1,9 +1,18 @@ +import 'package:flutter/cupertino.dart'; + import '../../../../module.dart'; +import '../sub_header.dart'; class DrugAnnotationCard extends StatelessWidget { - const DrugAnnotationCard(this.drug); + const DrugAnnotationCard( + this.drug, { + required this.isActive, + required this.setActivity, + }); final Drug drug; + final bool isActive; + final void Function(bool?) setActivity; @override Widget build(BuildContext context) { @@ -12,6 +21,8 @@ class DrugAnnotationCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + SubHeader(context.l10n.drugs_page_header_druginfo), + SizedBox(height: 12), Text(drug.annotations.indication), SizedBox(height: 8), Table(defaultColumnWidth: IntrinsicColumnWidth(), children: [ @@ -22,6 +33,38 @@ class DrugAnnotationCard extends StatelessWidget { drug.annotations.brandNames.join(', ')), ] ]), + SizedBox(height: 4), + Divider(color: PharMeTheme.borderColor), + SizedBox(height: 4), + SubHeader(context.l10n.drugs_page_header_active), + CheckboxListTile( + activeColor: PharMeTheme.primaryColor, + title: Text(context.l10n.drugs_page_active), + value: isActive, + onChanged: (newValue) => showCupertinoModalPopup( + context: context, + builder: (context) => CupertinoAlertDialog( + title: Text(context.l10n.drugs_page_active_warn_header), + content: Text(context.l10n.drugs_page_active_warn), + actions: [ + CupertinoDialogAction( + isDefaultAction: true, + onPressed: () => Navigator.pop(context, 'Cancel'), + child: Text(context.l10n.action_cancel), + ), + CupertinoDialogAction( + isDestructiveAction: true, + onPressed: () { + Navigator.pop(context, 'OK'); + setActivity(newValue); + }, + child: Text(context.l10n.action_continue), + ), + ], + ), + ), + controlAffinity: ListTileControlAffinity.leading, + ), ], ), ), diff --git a/app/lib/common/pages/drug/widgets/annotation_cards/guideline.dart b/app/lib/common/pages/drug/widgets/annotation_cards/guideline.dart index bca851ded..1cf0af79f 100644 --- a/app/lib/common/pages/drug/widgets/annotation_cards/guideline.dart +++ b/app/lib/common/pages/drug/widgets/annotation_cards/guideline.dart @@ -17,7 +17,9 @@ class GuidelineAnnotationCard extends StatelessWidget { _buildHeader(context), SizedBox(height: 12), _buildCard(context), - SizedBox(height: PharMeTheme.mediumSpace), + SizedBox(height: 8), + Divider(color: PharMeTheme.borderColor), + SizedBox(height: 8), _buildSourcesSection(context), SizedBox(height: 12), ]), @@ -34,7 +36,8 @@ class GuidelineAnnotationCard extends StatelessWidget { color: guideline.annotations.warningLevel.color, child: Padding( padding: EdgeInsets.all(12), - child: Column(children: [ + child: + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Icon(guideline.annotations.warningLevel.icon, color: PharMeTheme.onSurfaceText), @@ -55,15 +58,22 @@ class GuidelineAnnotationCard extends StatelessWidget { } Widget _buildHeader(BuildContext context) { - final geneDescriptions = guideline.lookupkey.keys.map((geneSymbol) => - '$geneSymbol (${UserData.instance.lookups![geneSymbol]!.phenotype})'); + final geneDescriptions = guideline.lookupkey.keys.map((geneSymbol) { + final overwritingDrug = UserData.overwrittenLookup(geneSymbol)?.key; + final hint = overwritingDrug.isNotNullOrEmpty + ? '(${context.l10n.drugs_page_overwritten_phenotype(overwritingDrug!)})' + : ''; + final genePhenotype = + '$geneSymbol: ${UserData.phenotypeFor(geneSymbol)!}'; + return [genePhenotype, hint].join(' '); + }); return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ SubHeader(context.l10n.drugs_page_your_genome), SizedBox(height: 12), Text( geneDescriptions.join(', '), style: PharMeTheme.textTheme.bodyLarge!, - ) + ), ]); } diff --git a/app/lib/common/pages/drug/widgets/disclaimer.dart b/app/lib/common/pages/drug/widgets/disclaimer.dart index b49abdb5f..bff430eab 100644 --- a/app/lib/common/pages/drug/widgets/disclaimer.dart +++ b/app/lib/common/pages/drug/widgets/disclaimer.dart @@ -4,7 +4,9 @@ import '../../../l10n.dart'; import '../../../theme.dart'; class Disclaimer extends StatelessWidget { - const Disclaimer(); + const Disclaimer({this.text}); + + final String? text; @override Widget build(BuildContext context) { @@ -24,7 +26,7 @@ class Disclaimer extends StatelessWidget { SizedBox(width: 8), Flexible( child: Text( - context.l10n.drugs_page_disclaimer, + text ?? context.l10n.drugs_page_disclaimer, style: PharMeTheme.textTheme.labelMedium!.copyWith( fontWeight: FontWeight.w100, ), diff --git a/app/lib/common/theme.dart b/app/lib/common/theme.dart index 93840cde8..e354ced8c 100644 --- a/app/lib/common/theme.dart +++ b/app/lib/common/theme.dart @@ -71,11 +71,6 @@ class PharMeTheme { static const errorColor = Color(0xccf52a2a); static final borderColor = Colors.black.withOpacity(.2); - static Icon starIcon({required bool isStarred, double? size}) { - return Icon(isStarred ? Icons.star_rounded : Icons.star_border_rounded, - size: size, color: primaryColor); - } - static const mediumSpace = 16.0; } diff --git a/app/lib/common/utilities/drug_utils.dart b/app/lib/common/utilities/drug_utils.dart index 6c577b2ee..2b6d925e8 100644 --- a/app/lib/common/utilities/drug_utils.dart +++ b/app/lib/common/utilities/drug_utils.dart @@ -20,8 +20,6 @@ Future updateCachedDrugs() async { final dataResponse = await get(anniUrl('data')); if (dataResponse.statusCode != 200) throw Exception(); - final drugs = - AnniDataResponse.fromJson(jsonDecode(dataResponse.body)).data.drugs; - CachedDrugs.instance.drugs = drugs.filterUserGuidelines(); + CachedDrugs.instance.drugs = AnniDataResponse.fromJson(jsonDecode(dataResponse.body)).data.drugs; await CachedDrugs.save(); } diff --git a/app/lib/common/utilities/genome_data.dart b/app/lib/common/utilities/genome_data.dart index bd79a56e1..78b0293d9 100644 --- a/app/lib/common/utilities/genome_data.dart +++ b/app/lib/common/utilities/genome_data.dart @@ -26,7 +26,7 @@ Future _saveDiplotypeResponse(Response response) async { final diplotypes = diplotypesFromHTTPResponse(response).filterValidDiplotypes(); - UserData.instance.diplotypes = diplotypes; + UserData.instance.diplotypes = {for (var d in diplotypes) d.gene: d}; await UserData.save(); // invalidate cached drugs because lookups may have changed and we need to // refilter the matching guidelines @@ -55,14 +55,17 @@ Future fetchAndSaveLookups() async { // ignore: omit_local_variable_types final Map matchingLookups = {}; // extract the matching lookups - for (final diplotype in usersDiplotypes) { - if (diplotype.genotype.contains('-')) { - // TODO(kolioOtSofia): what to do with genotypes that contain - - } + for (final diplotype in usersDiplotypes.values) { // the gene and the genotype build the key for the hashmap final key = '${diplotype.gene}__${diplotype.genotype}'; - final temp = lookupsHashMap[key]; - if (temp != null) matchingLookups[diplotype.gene] = temp; + final lookup = lookupsHashMap[key]; + if (lookup == null) continue; + // uncomment to print literal mismatches between lab/CPIC phenotypes + // if (diplotype.phenotype != lookup.phenotype) { + // print( + // 'Lab phenotype ${diplotype.phenotype} for ${diplotype.gene} (${diplotype.genotype}) is "${lookup.phenotype}" for CPIC'); + // } + matchingLookups[diplotype.gene] = lookup; } UserData.instance.lookups = matchingLookups; diff --git a/app/lib/common/utilities/pdf_utils.dart b/app/lib/common/utilities/pdf_utils.dart index b9671945a..e8733e62b 100644 --- a/app/lib/common/utilities/pdf_utils.dart +++ b/app/lib/common/utilities/pdf_utils.dart @@ -75,7 +75,7 @@ List _buildGuidelinePart(Guideline guideline) { title: 'Relevant gene phenotypes: ', text: guideline.lookupkey.keys .map((geneSymbol) => - '$geneSymbol: ${UserData.instance.lookups![geneSymbol]!}') + '$geneSymbol: ${UserData.phenotypeFor(geneSymbol)!}') .join(', ')), ), pw.SizedBox(height: 8, width: double.infinity), diff --git a/app/lib/common/widgets/context_menu.dart b/app/lib/common/widgets/context_menu.dart new file mode 100644 index 000000000..bb59f9565 --- /dev/null +++ b/app/lib/common/widgets/context_menu.dart @@ -0,0 +1,100 @@ +import 'package:popover/popover.dart'; + +import '../module.dart'; + +class ContextMenu extends StatelessWidget { + const ContextMenu({ + super.key, + required this.items, + required this.child, + }); + + final List items; + final Widget child; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + showPopover( + context: context, + bodyBuilder: (context) => Padding( + padding: EdgeInsets.symmetric(horizontal: 12), + child: Container( + decoration: BoxDecoration( + color: PharMeTheme.onSurfaceColor, + borderRadius: BorderRadius.circular(8), + ), + child: IntrinsicWidth( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: items + .mapIndexed( + (index, item) => (index == items.count() - 1) + ? item + : Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + width: 0.5, + color: PharMeTheme.borderColor), + )), + child: item)) + .toList(), + ), + ), + ), + ), + direction: PopoverDirection.bottom, + arrowHeight: 0, + arrowWidth: 0, + transitionDuration: Duration(milliseconds: 100), + barrierColor: Color.fromRGBO(0, 0, 0, 0.05), + backgroundColor: Color.fromRGBO(1, 1, 1, 0), + shadow: [], + ); + }, + child: child); + } +} + +class ContextMenuCheckmark extends StatelessWidget { + const ContextMenuCheckmark( + {super.key, + required this.label, + required this.setState, + this.initialState = false}); + + final String label; + final void Function(bool) setState; + final bool initialState; + + @override + Widget build(BuildContext context) { + var state = initialState; + return StatefulBuilder( + builder: (context, rebuild) => GestureDetector( + onTap: () { + rebuild(() { + state = !state; + setState(state); + }); + }, + child: Padding( + padding: EdgeInsets.all(12), + child: Row( + children: [ + if (state) + Icon(Icons.check_rounded, size: 16) + else + SizedBox(width: 16, height: 16), + SizedBox(width: 8), + Expanded(child: Text(label)), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/common/widgets/module.dart b/app/lib/common/widgets/module.dart index 2cbbfd4ef..75550056b 100644 --- a/app/lib/common/widgets/module.dart +++ b/app/lib/common/widgets/module.dart @@ -1,4 +1,5 @@ export 'app.dart'; +export 'context_menu.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 834e00674..93a3d3805 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -1,4 +1,7 @@ { + "action_cancel": "Cancel", + "action_continue": "Continue", + "auth_choose_lab": "Please select your data provider", "auth_sign_in": "Sign in", "auth_success": "Successfully imported data", @@ -6,7 +9,7 @@ "err_could_not_retrieve_access_token": "An unexpected error occurred while retrieving the access token", "err_fetch_user_data_failed": "An unexpected error occurred while fetching your genomic data", "err_generic": "Error!", - "err_no_starred_drugs": "You have no starred drugs! Try disabling the filter by tapping the star icon above.", + "err_no_drugs": "Try adjusting the filters next to the search bar above.", "faq_page_description": "Here you can find answers to common questions about PGx", "faq_pharmacogenomics": "Pharmacogenomics", @@ -17,13 +20,33 @@ "general_retry": "Retry", "search_page_tooltip_search": "Search for drugs by their name, brand name or class.", + "search_page_filter_inactive": "Inactive drugs", + "search_page_filter_green": "Green warning level", + "search_page_filter_yellow": "Yellow warning level", + "search_page_filter_red": "Red warning level", + "search_page_filter_gray": "No warning level", "drugs_page_disclaimer": "This assessment is ONLY based on your genetics. Important factors like weight, age and pre-existing conditions are not considered.", + "drugs_page_overwritten_phenotype": "adjusted based on you taking {drugName}", + "@drugs_page_overwritten_phenotype": { + "description": "Disclaimer for when the phenotype has been adjusted based on an active drug", + "placeholders": { + "drugName": { + "type": "String", + "example": "bupropion" + } + } + }, "drugs_page_your_genome": "Your genome", "drugs_page_header_further_info": "Further Information", "drugs_page_header_synonyms": "Synonyms", "drugs_page_header_drugclass": "Drug class", - "drugs_page_header_druginfo": "Drug Information", + "drugs_page_header_drug": "Drug", + "drugs_page_header_druginfo": "Information", + "drugs_page_header_active": "Activity", + "drugs_page_active": "I am actively taking this drug.", + "drugs_page_active_warn_header": "Are you sure you want to change the activity status?", + "drugs_page_active_warn": "This may adjust the guidelines you receive for other drugs.", "drugs_page_header_guideline": "Clinical Guideline", "drugs_page_header_recommendation": "Recommendation", "drugs_page_no_guidelines_for_phenotype": "We couldn't find a guideline matching your genetics.", @@ -96,8 +119,6 @@ "onboarding_5_text": "After signing in to the lab, your genetic data is sent straight onto your phone.\n\nOur servers know nothing about you, neither your identity nor your genetics.", "settings_page_account_settings": "Account Settings", - "settings_page_cancel": "Cancel", - "settings_page_continue": "Continue", "settings_page_delete_data": "Delete App Data", "settings_page_delete_data_text": "Are you sure that you want to delete all app data? This also includes your genomic data.", "settings_page_more": "More", diff --git a/app/lib/report/pages/gene.dart b/app/lib/report/pages/gene.dart index 42258dacf..77c2a34df 100644 --- a/app/lib/report/pages/gene.dart +++ b/app/lib/report/pages/gene.dart @@ -31,8 +31,8 @@ class GenePage extends StatelessWidget { _buildRow( context.l10n.gene_page_genotype, phenotype.genotype, tooltip: context.l10n.gene_page_genotype_tooltip), - _buildRow( - context.l10n.gene_page_phenotype, phenotype.phenotype, + _buildRow(context.l10n.gene_page_phenotype, + UserData.phenotypeFor(phenotype.geneSymbol)!, tooltip: context.l10n.gene_page_phenotype_tooltip), ]), ), diff --git a/app/lib/report/pages/report.dart b/app/lib/report/pages/report.dart index fa18251d9..41e1ee77b 100644 --- a/app/lib/report/pages/report.dart +++ b/app/lib/report/pages/report.dart @@ -35,7 +35,7 @@ class GeneCard extends StatelessWidget { Text(phenotype.geneSymbol, style: PharMeTheme.textTheme.titleMedium), SizedBox(height: 8), - Text(phenotype.phenotype, + Text(UserData.phenotypeFor(phenotype.geneSymbol)!, style: PharMeTheme.textTheme.titleSmall), ], ), diff --git a/app/lib/search/pages/cubit.dart b/app/lib/search/pages/cubit.dart index df60421e4..2a0de9c82 100644 --- a/app/lib/search/pages/cubit.dart +++ b/app/lib/search/pages/cubit.dart @@ -14,29 +14,34 @@ class SearchCubit extends Cubit { Timer? searchTimeout; String searchValue = ''; - bool filterStarred = false; final duration = Duration(milliseconds: 500); - void search({String? query, bool? filterStarred}) { - this.filterStarred = filterStarred ?? this.filterStarred; - searchValue = query ?? searchValue; - + void search({ + String? query, + bool? showInactive, + Map? showWarningLevel, + }) { state.whenOrNull( - initial: loadDrugs, - loaded: (allDrugs, _) => emit(SearchState.loaded( - allDrugs, - allDrugs - .filter((drug) => - (!this.filterStarred || drug.isStarred()) && - drug.matches(query: searchValue)) - .toList())), - error: loadDrugs); + initial: loadDrugs, + loaded: (allDrugs, filter) => emit( + SearchState.loaded( + allDrugs, + FilterState.from( + filter, + query: query, + showInactive: showInactive, + showWarningLevel: showWarningLevel, + ), + ), + ), + error: loadDrugs, + ); } Future loadDrugs({bool updateIfNull = true}) async { final drugs = CachedDrugs.instance.drugs; if (drugs != null) { - _emitFilteredLoaded(drugs); + emit(SearchState.loaded(drugs, FilterState.initial())); return; } if (!updateIfNull) { @@ -53,19 +58,47 @@ class SearchCubit extends Cubit { } } - void toggleFilter() { - search(filterStarred: !filterStarred); - } + FilterState? get filter => state.whenOrNull(loaded: (_, filter) => filter); +} + +class FilterState { + FilterState({ + required this.query, + required this.showInactive, + required this.showWarningLevel, + }); - void _emitFilteredLoaded(List drugs) { - emit(SearchState.loaded( - drugs, - drugs - .filter((drug) => - (!filterStarred || drug.isStarred()) && - drug.matches(query: searchValue)) - .toList())); + FilterState.initial() + : this(query: '', showInactive: true, showWarningLevel: { + for (var level in WarningLevel.values) level: true + }); + + FilterState.from( + FilterState other, { + String? query, + bool? showInactive, + Map? showWarningLevel, + }) : this( + query: query ?? other.query, + showInactive: showInactive ?? other.showInactive, + showWarningLevel: { + for (var level in WarningLevel.values) + level: showWarningLevel?[level] ?? other.showWarningLevel[level]! + }, + ); + + final String query; + final bool showInactive; + final Map showWarningLevel; + + bool isAccepted(Drug drug) { + final warningLevel = drug.userGuideline()?.annotations.warningLevel; + return drug.matches(query: query) && + (drug.isActive() || showInactive) && + (showWarningLevel[warningLevel] ?? true); } + + List filter(List drugs) => drugs.filter(isAccepted).toList(); } @freezed @@ -73,6 +106,8 @@ class SearchState with _$SearchState { const factory SearchState.initial() = _InitialState; const factory SearchState.loading() = _LoadingState; const factory SearchState.loaded( - List allDrugs, List filteredDrugs) = _LoadedState; + List allDrugs, + FilterState filter, + ) = _LoadedState; const factory SearchState.error() = _ErrorState; } diff --git a/app/lib/search/pages/search.dart b/app/lib/search/pages/search.dart index bb7a88a47..b06e5284d 100644 --- a/app/lib/search/pages/search.dart +++ b/app/lib/search/pages/search.dart @@ -31,27 +31,51 @@ class SearchPage extends HookWidget { )), SizedBox(width: 12), TooltipIcon(context.l10n.search_page_tooltip_search), - IconButton( - onPressed: () => context.read().toggleFilter(), - icon: PharMeTheme.starIcon( - isStarred: context.read().filterStarred)), + buildFilter(context), ]), body: state.when( initial: () => [Container()], error: () => [errorIndicator(context.l10n.err_generic)], - loaded: (_, drugs) => _buildDrugsList(context, drugs), + loaded: (drugs, filter) => + _buildDrugsList(context, drugs, filter), loading: () => [loadingIndicator()], )); })); } - List _buildDrugsList(BuildContext context, List drugs) { - if (drugs.isEmpty && context.read().filterStarred) { - return [errorIndicator(context.l10n.err_no_starred_drugs)]; + Widget buildFilter(BuildContext context) { + final cubit = context.read(); + final filter = cubit.filter; + return ContextMenu( + items: [ + ContextMenuCheckmark( + label: context.l10n.search_page_filter_inactive, + setState: (state) => cubit.search(showInactive: state), + initialState: filter?.showInactive ?? false), + ...WarningLevel.values.map((level) => ContextMenuCheckmark( + label: { + WarningLevel.green: context.l10n.search_page_filter_green, + WarningLevel.yellow: context.l10n.search_page_filter_yellow, + WarningLevel.red: context.l10n.search_page_filter_red, + WarningLevel.none: context.l10n.search_page_filter_gray, + }[level]!, + setState: (state) => cubit.search(showWarningLevel: {level: state}), + initialState: filter?.showWarningLevel[level] ?? false)) + ], + child: Padding( + 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), - ...drugs.map((drug) => Column(children: [ + ...filteredDrugs.map((drug) => Column(children: [ Padding( padding: EdgeInsets.symmetric(horizontal: 8), child: DrugCard( @@ -76,7 +100,7 @@ class DrugCard extends StatelessWidget { @override Widget build(BuildContext context) { - final warningLevel = drug.highestWarningLevel(); + final warningLevel = drug.userGuideline()?.annotations.warningLevel; return RoundedCard( onTap: onTap, diff --git a/app/lib/settings/pages/settings.dart b/app/lib/settings/pages/settings.dart index 08ce86751..7cbbef48c 100644 --- a/app/lib/settings/pages/settings.dart +++ b/app/lib/settings/pages/settings.dart @@ -63,7 +63,7 @@ class SettingsPage extends StatelessWidget { actions: [ TextButton( onPressed: context.router.root.pop, - child: Text(context.l10n.settings_page_cancel), + child: Text(context.l10n.action_cancel), ), TextButton( onPressed: () async { @@ -71,7 +71,7 @@ class SettingsPage extends StatelessWidget { await context.router.replaceAll([LoginRouter()]); }, child: Text( - context.l10n.settings_page_continue, + context.l10n.action_continue, style: TextStyle(color: PharMeTheme.errorColor), ), ), diff --git a/app/pubspec.lock b/app/pubspec.lock index 7b36e5cec..ae070c7a8 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -834,6 +834,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.5.1" + popover: + dependency: "direct main" + description: + name: popover + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.8+1" process: dependency: transitive description: @@ -1246,5 +1253,5 @@ packages: source: hosted version: "3.1.1" sdks: - dart: ">=2.17.0 <3.0.0" - flutter: ">=3.0.0" + dart: ">=2.18.0 <3.0.0" + flutter: ">=3.3.0" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 97d1b7f67..3681435af 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: json_annotation: ^4.6.0 path_provider: ^2.0.11 pdf: ^3.8.1 + popover: ^0.2.8+1 shared_preferences: ^2.0.15 url_launcher: ^6.1.4