Skip to main content

fhir_questionnaire_r4 7.0.0 copy "fhir_questionnaire_r4: ^7.0.0" to clipboard
fhir_questionnaire_r4: ^7.0.0 copied to clipboard

Renders FHIR R4 Questionnaires, generates the QuestionnaireResponse, and lets code or an AI agent fill the form for the user.

example/lib/main.dart

import 'dart:async';
import 'dart:convert';
import 'dart:ui';

import 'package:example/attachment_utils.dart';
import 'package:example/questionnaire_samples.dart';
import 'package:fhir_r4/fhir_r4.dart' hide QuestionnaireItemType;
import 'package:fhir_questionnaire_r4/fhir_questionnaire_r4.dart';
import 'package:flutter/foundation.dart';
// json_field_editor still depends on the in-SDK Material library, so its
// JsonField expects the legacy InputDecoration and a legacy Material ancestor.
import 'package:flutter/material.dart' as legacy;
import 'package:material_ui/material_ui.dart';
import 'package:json_field_editor/json_field_editor.dart';

void main() {
  runApp(const MyApp());
}

StreamController<InputDecorationTheme?> inputDecorationThemeStream =
    StreamController<InputDecorationTheme>.broadcast();

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<InputDecorationTheme?>(
      stream: inputDecorationThemeStream.stream,
      initialData: null,
      builder: (context, snapshot) {
        return MaterialApp(
          title: 'FHIR Questionnaire Demo',
          scrollBehavior: const CustomScrollBehavior(),
          debugShowCheckedModeBanner: false,
          theme: ThemeData(
            colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
            useMaterial3: true,
            inputDecorationTheme: snapshot.data,
          ),
          // Lets legacy Material widgets (json_field_editor) resolve Theme and
          // MaterialLocalizations while that dependency is not migrated.
          builder: (context, child) =>
              // ignore: deprecated_member_use
              MaterialUiCompatibilityBridge(child: child!),
          home: const MyHomePage(),
        );
      },
    );
  }
}

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

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final List<({String name, Locale? value})> locales = [
    (name: 'System default', value: null),
    (name: 'English', value: const Locale('en', 'US')),
    (name: 'Spanish', value: const Locale('es', 'ES')),
    (name: 'French', value: const Locale('fr', 'FR')),
  ];
  final List<({String name, String value})> questionnaires = [
    (name: 'Generic', value: QuestionnaireSamples.sampleGeneric),
    (name: 'Signature', value: QuestionnaireSamples.sampleSignature),
    (name: 'PRAPARE', value: QuestionnaireSamples.samplePrapare),
    (name: 'PHQ-9', value: QuestionnaireSamples.samplePHQ9),
    (name: 'GAD-7', value: QuestionnaireSamples.sampleGAD7),
    (name: 'BMI', value: QuestionnaireSamples.sampleBMI),
    (name: 'FDR Communs', value: QuestionnaireSamples.fdrCommuns),
  ];
  final List<({String name, InputDecorationTheme value})>
  inputDecorationThemes = [
    (
      name: 'Default',
      value: const InputDecorationTheme(
        border: UnderlineInputBorder(),
        enabledBorder: UnderlineInputBorder(),
      ),
    ),
    (
      name: 'Outline',
      value: const InputDecorationTheme(
        contentPadding: EdgeInsets.only(left: 16, top: 12, bottom: 12),
        border: OutlineInputBorder(),
      ),
    ),
    (
      name: 'Outline Streched Rounded',
      value: const InputDecorationTheme(
        contentPadding: EdgeInsets.only(left: 16, top: 12, bottom: 12),
        border: OutlineInputBorder(
          borderRadius: BorderRadius.all(Radius.circular(28)),
        ),
      ),
    ),
    (
      name: 'Outline Streched Rounded Filled',
      value: const InputDecorationTheme(
        contentPadding: EdgeInsets.only(left: 16, top: 12, bottom: 12),
        border: OutlineInputBorder(
          borderRadius: BorderRadius.all(Radius.circular(28)),
        ),
        filled: true,
      ),
    ),
    (
      name: 'Outline Streched Rounded Filled No Borders',
      value: const InputDecorationTheme(
        contentPadding: EdgeInsets.only(left: 16, top: 12, bottom: 12),
        border: OutlineInputBorder(
          borderRadius: BorderRadius.all(Radius.circular(28)),
          borderSide: BorderSide(style: BorderStyle.none, width: 0),
        ),
        isDense: true,
        alignLabelWithHint: true,
        filled: true,
      ),
    ),
  ];
  static bool isValidJson(String? jsonString) {
    if (jsonString == null) {
      return false;
    }
    try {
      json.decode(jsonString);
      return true;
    } on FormatException catch (_) {
      return false;
    }
  }

  Locale? selectedLocale;
  String selectedQuestionnaire = QuestionnaireSamples.sampleGeneric;
  InputDecorationTheme? selectedInputDecorationTheme;
  final extraLocalizations = [QuestionnaireFrLocalization()];

  @override
  void initState() {
    super.initState();
    selectedInputDecorationTheme = inputDecorationThemes.first.value;
  }

  ThemeData theme = ThemeData();
  @override
  Widget build(BuildContext context) {
    theme = Theme.of(context);
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('FHIR Questionnaire Demo'),
      ),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              DropdownButtonFormField<String>(
                decoration: const InputDecoration(
                  label: Text('Select a Questionnaire sample'),
                ),
                initialValue: selectedQuestionnaire,
                items: questionnaires
                    .map(
                      (e) => DropdownMenuItem<String>(
                        value: e.value,
                        child: Text(e.name),
                      ),
                    )
                    .toList(),
                onChanged: (value) {
                  if (value != null) {
                    selectedQuestionnaire = value;
                  }
                },
              ),
              const SizedBox(height: 8.0),
              TextButton.icon(
                onPressed: () {
                  showDialog(
                    context: context,
                    builder: (context) {
                      String? nameError;
                      String? jsonError;
                      final nameController = JsonTextFieldController();
                      final jsonController = JsonTextFieldController();
                      return Dialog(
                        child: Padding(
                          padding: const EdgeInsets.all(16.0),
                          child: StatefulBuilder(
                            builder: (context, dialogSetState) => Column(
                              mainAxisSize: MainAxisSize.min,
                              children: [
                                Text(
                                  'Add a Questionnaire from JSON',
                                  textAlign: TextAlign.center,
                                  style: theme.textTheme.titleLarge,
                                ),
                                const SizedBox(height: 8),
                                TextField(
                                  controller: nameController,
                                  decoration: InputDecoration(
                                    labelText: 'Questionnaire Name',
                                    errorText: nameError,
                                    contentPadding: EdgeInsets.only(
                                      left: 16,
                                      top: 12,
                                      bottom: 12,
                                    ),
                                    border: OutlineInputBorder(
                                      borderRadius: BorderRadius.all(
                                        Radius.circular(28),
                                      ),
                                    ),
                                    filled: true,
                                  ),
                                ),
                                ConstrainedBox(
                                  constraints: BoxConstraints(
                                    maxHeight:
                                        MediaQuery.of(context).size.height *
                                        0.6,
                                  ),
                                  child: SingleChildScrollView(
                                    child: Padding(
                                      padding: const EdgeInsets.symmetric(
                                        vertical: 16,
                                      ),
                                      child: legacy.Material(
                                        type: legacy.MaterialType.transparency,
                                        child: JsonField(
                                          controller: jsonController,
                                          isFormatting: true,
                                          showErrorMessage: true,
                                          maxLines: null,
                                          decoration: legacy.InputDecoration(
                                            labelText: 'Questionnaire JSON',
                                            errorText: jsonError,
                                            contentPadding: EdgeInsets.only(
                                              left: 16,
                                              top: 12,
                                              bottom: 12,
                                            ),
                                            border: legacy.OutlineInputBorder(
                                              borderRadius: BorderRadius.all(
                                                Radius.circular(28),
                                              ),
                                            ),
                                            filled: true,
                                          ),
                                        ),
                                      ),
                                    ),
                                  ),
                                ),
                                const SizedBox(height: 16),
                                Row(
                                  children: [
                                    Expanded(
                                      child: ElevatedButton(
                                        onPressed: () => jsonController
                                            .formatJson(sortJson: true),
                                        child: const Text('Format JSON'),
                                      ),
                                    ),
                                    const SizedBox(width: 8),
                                    Expanded(
                                      child: ElevatedButton(
                                        onPressed: () {
                                          nameError = jsonError = null;
                                          if (nameController.text.isEmpty) {
                                            nameError = 'Name is required';
                                          }
                                          if (nameError == null &&
                                              questionnaires.any(
                                                (item) =>
                                                    item.name ==
                                                    nameController.text,
                                              )) {
                                            nameError = 'A Questionnaire with this name already exists';
                                          }
                                          if (jsonController.text.isEmpty) {
                                            jsonError = 'JSON is required';
                                          }
                                          if (jsonError == null &&
                                              !isValidJson(
                                                jsonController.text,
                                              )) {
                                            jsonError = 'Invalid JSON';
                                          }
                                          if (jsonError == null &&
                                              questionnaires.any(
                                                (item) =>
                                                    item.value ==
                                                    jsonController.text,
                                              )) {
                                            jsonError = 'A Questionnaire with this JSON already exists';
                                          }
                                          if (nameError != null ||
                                              jsonError != null) {
                                            dialogSetState(() {});
                                            return;
                                          }
                                          questionnaires.add((
                                            name: nameController.text,
                                            value: jsonController.text,
                                          ));
                                          selectedQuestionnaire =
                                              jsonController.text;
                                          setState(() {});
                                          Navigator.pop(context);
                                        },
                                        style: ElevatedButton.styleFrom(
                                          backgroundColor:
                                              theme.colorScheme.primary,
                                          foregroundColor:
                                              theme.colorScheme.onPrimary,
                                        ),
                                        child: const Text('Add'),
                                      ),
                                    ),
                                  ],
                                ),
                              ],
                            ),
                          ),
                        ),
                      );
                    },
                  );
                },
                icon: const Icon(Icons.add),
                label: const Text('Add a Questionnaire from JSON'),
              ),
              const SizedBox(height: 16.0),
              DropdownButtonFormField<Locale?>(
                decoration: const InputDecoration(
                  label: Text('Select the Questionnaire locale'),
                ),
                initialValue: selectedLocale,
                items: locales
                    .map(
                      (e) => DropdownMenuItem<Locale>(
                        value: e.value,
                        child: Text(e.name),
                      ),
                    )
                    .toList(),
                onChanged: (value) {
                  selectedLocale = value;
                },
              ),
              const SizedBox(height: 16.0),
              DropdownButtonFormField<InputDecorationTheme?>(
                decoration: const InputDecoration(
                  label: Text('Select input decoration theme '),
                ),
                initialValue: selectedInputDecorationTheme,
                items: inputDecorationThemes
                    .map(
                      (e) => DropdownMenuItem<InputDecorationTheme>(
                        value: e.value,
                        child: SizedBox(
                          width: MediaQuery.of(context).size.width * 0.78,
                          child: Text(e.name),
                        ),
                      ),
                    )
                    .toList(),
                onChanged: (value) {
                  selectedInputDecorationTheme = value;
                  inputDecorationThemeStream.add(selectedInputDecorationTheme);
                },
              ),
            ],
          ),
        ),
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
      floatingActionButton: FloatingActionButton.extended(
        extendedPadding: const EdgeInsets.symmetric(horizontal: 32.0),
        onPressed: () {
          Navigator.push(
            context,
            MaterialPageRoute(
              builder: (context) => QuestionnairePage(
                questionnaire: questionnaire,
                locale: selectedLocale,
                localizations: extraLocalizations,
              ),
            ),
          );
        },
        label: const Text('Open Questionnaire'),
      ),
    );
  }

  Questionnaire get questionnaire =>
      Questionnaire.fromJsonString(selectedQuestionnaire);
}

class QuestionnairePage extends StatefulWidget {
  final Questionnaire questionnaire;
  final Locale? locale;
  final List<QuestionnaireBaseLocalization>? localizations;
  const QuestionnairePage({
    super.key,
    required this.questionnaire,
    this.locale,
    this.localizations,
  });

  @override
  State createState() => QuestionnairePageState();
}

class QuestionnairePageState extends State<QuestionnairePage> {
  bool loading = true;
  ThemeData theme = ThemeData();
  final practitioner = QuestionnairePerson(
    reference: Reference(
      reference: FhirString("Practitioner/123"),
      display: FhirString("Greg House"),
    ),
    name: 'Greg House',
    title: 'General Clinician',
  );
  final patient = QuestionnairePerson(
    reference: Reference(
      reference: FhirString("Patient/456"),
      display: FhirString("John Doe"),
    ),
    name: 'John Doe',
  );

  final formController = QuestionnaireFormController();

  @override
  void initState() {
    super.initState();
    Future.delayed(
      const Duration(seconds: 1),
      () => setState(() => loading = false),
    );
  }

  @override
  void dispose() {
    formController.dispose();
    super.dispose();
  }

  /// Walks the form from code: highlights each unanswered question, scrolls to
  /// it and answers it with a sample value.
  Future<void> fillFromCode() async {
    if (!formController.isAttached) return;
    for (final item in formController.items) {
      if (!mounted) return;
      if (item.hidden || !item.enabled || item.readOnly) continue;
      if (formController.answerOf(item.uid).isNotEmpty) continue;
      final Object? value = switch (item.type) {
        QuestionnaireItemType.string ||
        QuestionnaireItemType.text => 'Sample answer',
        QuestionnaireItemType.integer => 5,
        QuestionnaireItemType.decimal => 1.5,
        QuestionnaireItemType.boolean => true,
        QuestionnaireItemType.date ||
        QuestionnaireItemType.dateTime ||
        QuestionnaireItemType.time => DateTime.now(),
        QuestionnaireItemType.choice || QuestionnaireItemType.openChoice
            when item.options.isNotEmpty =>
          item.repeats ? [item.options.first] : item.options.first,
        _ => null,
      };
      if (value == null) continue;
      formController.highlight(item.uid);
      await formController.scrollTo(item.uid);
      formController.setAnswer(item.uid, value);
      await Future.delayed(const Duration(milliseconds: 600));
    }
    formController.clearHighlight();
  }

  @override
  Widget build(BuildContext context) {
    theme = Theme.of(context);
    return Scaffold(
      appBar: AppBar(
        title: const Text('Questionnaire'),
        actions: [
          IconButton(
            tooltip: 'Fill from code',
            icon: const Icon(Icons.auto_fix_high),
            onPressed: fillFromCode,
          ),
        ],
      ),
      body: QuestionnaireView(
        key: ValueKey(loading),
        formController: formController,
        questionnaire: widget.questionnaire,
        onAttachmentLoaded: onAttachmentLoaded,
        locale: widget.locale,
        localizations: widget.localizations,
        isLoading: loading,
        onSubmit: onSubmit,
        author: practitioner,
        source: practitioner,
        subject: patient,
        whoSigns: practitioner,
        signsOnBehalfOf: patient,
      ),
    );
  }

  Future<Attachment?> onAttachmentLoaded() async {
    return AttachmentUtils.pickAttachment(context);
  }

  void onSubmit(QuestionnaireResponse questionnaireResponse) async {
    String json = jsonEncode(questionnaireResponse.toJson());
    var prettyJson = const JsonEncoder.withIndent('  ')
        .convert(questionnaireResponse.toJson());
    debugPrint('''
      ========================================================================
      $prettyJson
      ========================================================================
      ''');
    showDialog(
      context: context,
      builder: (context) => Dialog(
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Text(
                'Questionnaire Response',
                textAlign: TextAlign.center,
                style: theme.textTheme.titleLarge,
              ),
              const SizedBox(height: 8),
              ConstrainedBox(
                constraints: BoxConstraints(
                  maxHeight: MediaQuery.of(context).size.height * 0.7,
                ),
                child: SingleChildScrollView(
                  child: Padding(
                    padding: const EdgeInsets.all(16.0),
                    child: legacy.Material(
                      type: legacy.MaterialType.transparency,
                      child: JsonField(
                        controller: JsonTextFieldController()..text = json,
                        isFormatting: true,
                        showErrorMessage: true,
                        doInitFormatting: true,
                        readOnly: true,
                        showCursor: true,
                        enableInteractiveSelection: true,
                        maxLines: null,
                        decoration: legacy.InputDecoration(
                          contentPadding: EdgeInsets.only(
                            left: 16,
                            top: 12,
                            bottom: 12,
                          ),
                          border: legacy.OutlineInputBorder(
                            borderRadius: BorderRadius.all(Radius.circular(28)),
                          ),
                          filled: true,
                        ),
                      ),
                    ),
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

/// French localizations
class QuestionnaireFrLocalization extends QuestionnaireBaseLocalization {
  QuestionnaireFrLocalization() : super(const Locale('fr', 'FR'));

  @override
  String get btnSubmit => 'Soumettre';
  @override
  String get btnUpload => 'Télécharger';
  @override
  String get btnChange => 'Changement';
  @override
  String get btnRemove => 'Retirer';
  @override
  String get btnClearSignature => 'Effacer';
  @override
  String get btnCancel => 'Annuler';
  @override
  String get btnDone => 'Terminé';
  @override
  String get textOtherOption => 'Autre option';
  @override
  String get textDate => 'Date';
  @override
  String get textTime => 'Temps';
  @override
  String get textSignature => 'Signature';
  @override
  String get textTapToSign => 'Appuyez pour signer';
  @override
  String get textSignedBy => 'Par';
  @override
  String get textSignedOnBehalfOf => 'Au nom de';
  @override
  String get exceptionNoEmptyField => 'Ce champ est obligatoire.';
  @override
  String get exceptionValueMustBeAPositiveIntegerNumber =>
      'La valeur doit être un nombre entier positif.';
  @override
  String get exceptionValueMustBeAPositiveNumber =>
      'La valeur doit être un nombre positif.';
  @override
  String get exceptionInvalidUrl => 'Invalid url.';
  @override
  String exceptionValueOutOfRange(dynamic minValue, dynamic maxValue) =>
      'La valeur doit être comprise entre $minValue et $maxValue.';
  @override
  String exceptionValueMinRange(dynamic minValue) =>
      'La valeur doit être au minimum $minValue.';
  @override
  String exceptionValueMaxRange(dynamic maxValue) =>
      'La valeur doit être au maximum $maxValue.';
  @override
  String exceptionTextLength(dynamic minLength, dynamic maxLength) =>
      'Le texte doit contenir au moins des caractères $minLength et au maximum $maxLength.';
  @override
  String exceptionTextMinLength(dynamic minLength) =>
      'Le texte doit contenir au maximum des caractères $minLength.';
  @override
  String exceptionTextMaxLength(dynamic maxLength) =>
      'Le texte doit contenir au maximum des caractères $maxLength.';
}

class CustomScrollBehavior extends MaterialScrollBehavior {
  static const _webScrollPhysics = BouncingScrollPhysics(
    parent: RangeMaintainingScrollPhysics(),
  );

  const CustomScrollBehavior() : super();

  // Override behavior methods and getters like dragDevices
  @override
  Set<PointerDeviceKind> get dragDevices => PointerDeviceKind.values.toSet();

  @override
  ScrollPhysics getScrollPhysics(BuildContext context) =>
      kIsWeb ? _webScrollPhysics : super.getScrollPhysics(context);
}
1
likes
145
points
612
downloads

Documentation

API reference

Publisher

verified publisherdev.lamt.dev

Weekly Downloads

Renders FHIR R4 Questionnaires, generates the QuestionnaireResponse, and lets code or an AI agent fill the form for the user.

Topics

#fhir #questionnaire #hl7 #r4 #ai

License

BSD-2-Clause (license)

Dependencies

collection, fhir_r4, fhir_r4_path, flutter, hand_signature, intl, material_ui, shimmer, validators

More

Packages that depend on fhir_questionnaire_r4