Skip to content

Commit

Permalink
Merge pull request #580 from hpi-studyu/issue/564-future-proof-deseri…
Browse files Browse the repository at this point in the history
…alization-with-exceptions

fix: future-proof deserialization of studies with exceptions
  • Loading branch information
hig-dev authored Mar 13, 2024
2 parents d4bc7b5 + 0a26441 commit c9c28f6
Show file tree
Hide file tree
Showing 31 changed files with 5,354 additions and 6,363 deletions.
1 change: 1 addition & 0 deletions .github/workflows/uml-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ on:
- '!designer_v2/integration_test/**'
- '!designer_v2/test_driver/**'
- 'app/**/*.dart'
workflow_dispatch:

concurrency:
group: ${{ github.ref }}-uml-docs
Expand Down
3 changes: 3 additions & 0 deletions app/lib/l10n/app_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
"study_selection_single": "Sie können zu jeder Zeit maximal an einer Studie teilnehmen.",
"study_selection_single_why": "Warum?",
"study_selection_single_reason": "Wenn Sie zur selben Zeit an mehreren Studien teilnehmen würde, könnten die Kombination der Interventionen die Ergebnisse verfälschen.",
"study_selection_unsupported_title": "Veraltete App-Version",
"study_selection_unsupported": "Die Studie, an der Sie teilnehmen möchten, ist nicht mit Ihrer App-Version kompatibel. Bitte aktualisieren Sie die App auf die neueste Version.",
"study_selection_hidden_studies": "Einige Studien konnten nicht angezeigt werden, da Ihre App-Version veraltet ist. Bitte aktualisieren Sie Ihre App, um alle verfügbaren Studien zu sehen.",
"study_overview_title": "Übersicht",
"eligibility_questionnaire_title": "Fragebogen",
"please_answer_eligibility": "Bitte beantworten Sie ein paar Fragen um sicherzugehen, dass diese Studie für Sie geeignet ist",
Expand Down
3 changes: 3 additions & 0 deletions app/lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
"study_selection_single": "You can only participate in one study at a time.",
"study_selection_single_why": "Why?",
"study_selection_single_reason": "If you were to participate in multiple studies at a time, the interventions of these studies might interfere with one another and alter the results.",
"study_selection_unsupported_title": "Outdated app version",
"study_selection_unsupported": "The study you are trying to join is not compatible with your app version. Please update the app to the latest version.",
"study_selection_hidden_studies": "Some studies couldn't be shown, because your app version is outdated. Please update your app to see all available studies.",
"study_overview_title": "Overview",
"eligibility_questionnaire_title": "Questionnaire",
"please_answer_eligibility": "Please answer a few questions to make sure that you can safely participate in this study.",
Expand Down
2 changes: 1 addition & 1 deletion app/lib/screens/app_onboarding/preview.dart
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ class Preview {
if (selectedStudyObjectId != null) {
try {
if (selectedRoute == '/intervention') {
final List<StudySubject> studySubjects = await SupabaseQuery.getAll<StudySubject>(
final studySubjects = await SupabaseQuery.getAll<StudySubject>(
selectedColumns: [
'*',
'study!study_subject_studyId_fkey(*)',
Expand Down
85 changes: 77 additions & 8 deletions app/lib/screens/study/onboarding/study_selection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,33 @@ Future<void> navigateToStudyOverview(
Navigator.pushNamed(context, Routes.studyOverview);
}

class StudySelectionScreen extends StatelessWidget {
Future<void> showAppOutdatedDialog(BuildContext context) async {
await showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context)!.study_selection_unsupported_title),
content: Text(AppLocalizations.of(context)!.study_selection_unsupported),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("OK"),
),
],
),
);
}

class StudySelectionScreen extends StatefulWidget {
const StudySelectionScreen({super.key});

@override
State<StudySelectionScreen> createState() => _StudySelectionScreenState();
}

class _StudySelectionScreenState extends State<StudySelectionScreen> {
bool _hiddenStudies = false;
final publishedStudies = Study.publishedPublicStudies();

@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
Expand Down Expand Up @@ -74,19 +98,53 @@ class StudySelectionScreen extends StatelessWidget {
],
),
),
_hiddenStudies
? Column(
children: [
MaterialBanner(
padding: const EdgeInsets.all(8),
leading: Icon(
MdiIcons.exclamationThick,
color: Colors.orange,
size: 32,
),
content: Text(
AppLocalizations.of(context)!.study_selection_hidden_studies,
style: Theme.of(context).textTheme.titleSmall,
),
actions: const [SizedBox.shrink()],
backgroundColor: Colors.yellow[100],
),
const SizedBox(height: 16),
],
)
: const SizedBox.shrink(),
Expanded(
child: RetryFutureBuilder<List<Study>>(
tryFunction: () async => Study.publishedPublicStudies(),
successBuilder: (BuildContext context, List<Study>? studies) {
child: RetryFutureBuilder<ExtractionResult<Study>>(
tryFunction: () async => publishedStudies,
successBuilder: (BuildContext context, ExtractionResult<Study>? extractionResult) {
final studies = extractionResult!.extracted;
if (extractionResult is ExtractionFailedException<Study>) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_hiddenStudies) return;
debugPrint('${extractionResult.notExtracted.length} studies could not be extracted.');
setState(() {
_hiddenStudies = true;
});
});
}
return ListView.builder(
itemCount: studies!.length,
itemCount: studies.length,
itemBuilder: (context, index) {
final study = studies[index];
return Hero(
tag: 'study_tile_${studies[index].id}',
child: Material(
child: StudyTile.fromStudy(
study: studies[index],
onTap: () => navigateToStudyOverview(context, studies[index]),
study: study,
onTap: () async {
await navigateToStudyOverview(context, study);
},
),
),
);
Expand Down Expand Up @@ -186,7 +244,18 @@ class _InviteCodeDialogState extends State<InviteCodeDialog> {
}

if (studyResult != null) {
final study = Study.fromJson(studyResult);
Study study;
try {
study = Study.fromJson(studyResult);
// ignore: avoid_catching_errors
} on ArgumentError catch (error) {
// We are catching ArgumentError because unknown enums throw an ArgumentError
// and UnknownJsonTypeError is a subclass of ArgumentError
debugPrint('Study selection from invite failed: $error');
if (!context.mounted) return;
await showAppOutdatedDialog(context);
return;
}

if (!context.mounted) return;
Navigator.pop(context);
Expand Down
14 changes: 8 additions & 6 deletions core/lib/src/models/expressions/expression.dart
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import 'package:studyu_core/src/models/expressions/types/types.dart';
import 'package:studyu_core/src/models/questionnaire/questionnaire_state.dart';
import 'package:studyu_core/src/models/unknown_json_type_error.dart';

typedef ExpressionParser = Expression Function(Map<String, dynamic> data);

abstract class Expression {
static Map<String, ExpressionParser> expressionTypes = {
BooleanExpression.expressionType: (data) => BooleanExpression.fromJson(data),
ChoiceExpression.expressionType: (data) => ChoiceExpression.fromJson(data),
NotExpression.expressionType: (data) => NotExpression.fromJson(data),
};
static const String keyType = 'type';
String? type;

Expression(this.type);

factory Expression.fromJson(Map<String, dynamic> data) => expressionTypes[data[keyType]]!(data);
factory Expression.fromJson(Map<String, dynamic> data) => switch (data[keyType]) {
BooleanExpression.expressionType => BooleanExpression.fromJson(data),
ChoiceExpression.expressionType => ChoiceExpression.fromJson(data),
NotExpression.expressionType => NotExpression.fromJson(data),
_ => throw UnknownJsonTypeError(data[keyType])
};

Map<String, dynamic> toJson();

@override
Expand Down
10 changes: 5 additions & 5 deletions core/lib/src/models/interventions/intervention_task.dart
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import 'package:studyu_core/src/models/interventions/tasks/checkmark_task.dart';
import 'package:studyu_core/src/models/tasks/task.dart';
import 'package:studyu_core/src/models/unknown_json_type_error.dart';

typedef InterventionTaskParser = InterventionTask Function(Map<String, dynamic> data);

abstract class InterventionTask extends Task {
static Map<String, InterventionTaskParser> taskTypes = {
CheckmarkTask.taskType: (json) => CheckmarkTask.fromJson(json),
};

InterventionTask(super.type);

InterventionTask.withId(super.type) : super.withId();

factory InterventionTask.fromJson(Map<String, dynamic> data) => taskTypes[data[Task.keyType]]!(data);
factory InterventionTask.fromJson(Map<String, dynamic> data) => switch (data[Task.keyType]) {
CheckmarkTask.taskType => CheckmarkTask.fromJson(data),
_ => throw UnknownJsonTypeError(data[Task.keyType]),
};
}
1 change: 1 addition & 0 deletions core/lib/src/models/models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ export 'tables/study_subject.dart';
export 'tables/subject_progress.dart';
export 'tables/user.dart';
export 'tasks/tasks.dart';
export 'unknown_json_type_error.dart';
10 changes: 5 additions & 5 deletions core/lib/src/models/observations/observation.dart
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import 'package:studyu_core/src/models/observations/tasks/tasks.dart';
import 'package:studyu_core/src/models/tasks/task.dart';
import 'package:studyu_core/src/models/unknown_json_type_error.dart';

typedef ObservationTaskParser = Observation Function(Map<String, dynamic> data);

abstract class Observation extends Task {
static Map<String, ObservationTaskParser> taskTypes = {
QuestionnaireTask.taskType: (json) => QuestionnaireTask.fromJson(json),
};

Observation(super.type);

Observation.withId(super.type) : super.withId();

factory Observation.fromJson(Map<String, dynamic> data) => taskTypes[data[Task.keyType]]!(data);
factory Observation.fromJson(Map<String, dynamic> data) => switch (data[Task.keyType]) {
QuestionnaireTask.taskType => QuestionnaireTask.fromJson(data),
_ => throw UnknownJsonTypeError(data[Task.keyType]),
};
}
22 changes: 10 additions & 12 deletions core/lib/src/models/questionnaire/question.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,14 @@ import 'package:studyu_core/src/models/questionnaire/answer.dart';
import 'package:studyu_core/src/models/questionnaire/question_conditional.dart';
import 'package:studyu_core/src/models/questionnaire/questionnaire_state.dart';
import 'package:studyu_core/src/models/questionnaire/questions/questions.dart';
import 'package:studyu_core/src/models/unknown_json_type_error.dart';
import 'package:uuid/uuid.dart';

typedef QuestionParser = Question Function(Map<String, dynamic> data);

abstract class Question<V> {
static Map<String, QuestionParser> questionTypes = {
BooleanQuestion.questionType: (json) => BooleanQuestion.fromJson(json),
ChoiceQuestion.questionType: (json) => ChoiceQuestion.fromJson(json),
ScaleQuestion.questionType: (json) => ScaleQuestion.fromJson(json),
AnnotatedScaleQuestion.questionType: (json) => AnnotatedScaleQuestion.fromJson(json),
VisualAnalogueQuestion.questionType: (json) => VisualAnalogueQuestion.fromJson(json),
FreeTextQuestion.questionType: (json) => FreeTextQuestion.fromJson(json),
};
static const String keyType = 'type';
String type;

late String id;
String? prompt;
String? rationale;
Expand All @@ -29,9 +21,15 @@ abstract class Question<V> {

Question.withId(this.type) : id = const Uuid().v4();

factory Question.fromJson(Map<String, dynamic> data) {
return questionTypes[data[keyType]]!(data) as Question<V>;
}
factory Question.fromJson(Map<String, dynamic> data) => switch (data[keyType]) {
BooleanQuestion.questionType => BooleanQuestion.fromJson(data),
ChoiceQuestion.questionType => ChoiceQuestion.fromJson(data),
ScaleQuestion.questionType => ScaleQuestion.fromJson(data),
AnnotatedScaleQuestion.questionType => AnnotatedScaleQuestion.fromJson(data),
VisualAnalogueQuestion.questionType => VisualAnalogueQuestion.fromJson(data),
FreeTextQuestion.questionType => FreeTextQuestion.fromJson(data),
_ => throw UnknownJsonTypeError(data[keyType]),
} as Question<V>;

Map<String, dynamic> toJson();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ part 'choice_question.g.dart';

@JsonSerializable()
class ChoiceQuestion extends Question<List<String>> {
static String questionType = 'choice';
static const String questionType = 'choice';

bool multiple = false;
List<Choice> choices = [];
Expand Down
11 changes: 6 additions & 5 deletions core/lib/src/models/report/report_section.dart
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import 'package:studyu_core/src/models/report/sections/average_section.dart';
import 'package:studyu_core/src/models/report/sections/linear_regression_section.dart';
import 'package:studyu_core/src/models/unknown_json_type_error.dart';
import 'package:uuid/uuid.dart';

typedef SectionParser = ReportSection Function(Map<String, dynamic> data);

abstract class ReportSection {
static Map<String, SectionParser> sectionTypes = {
AverageSection.sectionType: (json) => AverageSection.fromJson(json),
LinearRegressionSection.sectionType: (json) => LinearRegressionSection.fromJson(json),
};
static const String keyType = 'type';
String type;
late String id;
Expand All @@ -19,7 +16,11 @@ abstract class ReportSection {

ReportSection.withId(this.type) : id = const Uuid().v4();

factory ReportSection.fromJson(Map<String, dynamic> data) => sectionTypes[data[keyType]]!(data);
factory ReportSection.fromJson(Map<String, dynamic> data) => switch (data[keyType]) {
AverageSection.sectionType => AverageSection.fromJson(data),
LinearRegressionSection.sectionType => LinearRegressionSection.fromJson(data),
_ => throw UnknownJsonTypeError(data[keyType]),
};
Map<String, dynamic> toJson();

@override
Expand Down
35 changes: 12 additions & 23 deletions core/lib/src/models/results/result.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import 'package:collection/collection.dart';
import 'package:json_annotation/json_annotation.dart';

import 'package:studyu_core/src/models/questionnaire/questionnaire_state.dart';
import 'package:studyu_core/src/models/unknown_json_type_error.dart';

part 'result.g.dart';

Expand All @@ -13,6 +13,7 @@ class Result<T> {
String? periodId;

static const String keyResult = 'result';

@JsonKey(includeToJson: false, includeFromJson: false)
late T result;

Expand All @@ -22,31 +23,19 @@ class Result<T> {

factory Result.parseJson(Map<String, dynamic> json) => _$ResultFromJson(json);

factory Result.fromJson(Map<String, dynamic> json) => _fromJson(json) as Result<T>;
factory Result.fromJson(Map<String, dynamic> json) => switch (json[keyType]) {
'QuestionnaireState' => Result<QuestionnaireState>.parseJson(json)
..result = QuestionnaireState.fromJson(List<Map<String, dynamic>>.from(json[keyResult] as List)),
'bool' => Result<bool>.parseJson(json)..result = json[keyResult] as bool,
_ => throw UnknownJsonTypeError(json[keyType]),
} as Result<T>;

Map<String, dynamic> toJson() {
final Map<String, dynamic> resultMap = switch (result) {
final QuestionnaireState questionnaireState => {keyResult: questionnaireState.toJson()},
bool() => {keyResult: result},
_ => {keyResult: _getUnsupportedResult()}
final Map<String, dynamic> resultMap = switch (type) {
'QuestionnaireState' => {keyResult: (result as QuestionnaireState).toJson()},
'bool' => {keyResult: result},
_ => throw ArgumentError('Unknown result type $type'),
};
return mergeMaps<String, dynamic>(_$ResultToJson(this), resultMap);
}

String _getUnsupportedResult() {
print('Unsupported question type: $T');
return '';
}

static Result _fromJson(Map<String, dynamic> data) {
switch (data[keyType] as String) {
case 'QuestionnaireState':
return Result<QuestionnaireState>.parseJson(data)
..result = QuestionnaireState.fromJson(List<Map<String, dynamic>>.from(data[keyResult] as List));
case 'bool':
return Result<bool>.parseJson(data)..result = data[keyResult] as bool;
default:
throw ArgumentError('Type ${data[keyType]} not supported.');
}
}
}
Loading

0 comments on commit c9c28f6

Please sign in to comment.