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