Flutter FHIR问卷处理插件fhir_questionnaire的使用

发布于 1周前 作者 songsunli 来自 Flutter

Flutter FHIR问卷处理插件fhir_questionnaire的使用

A Flutter包用于处理FHIR®问卷。FHIR®是HL7注册的商标,并且在获得HL7许可的情况下使用。使用FHIR商标并不构成对本产品的认可。

此包负责构建FHIR R4问卷的UI,处理行为和验证,并最终从用户答案生成问卷响应(QuestionnaireResponse)。

支持的问卷项

目前该包仅支持FHIR R4项目类型。

项目 支持
Group
Display
Question ☑️
Boolean
Decimal
Integer
Date
DateTime
Time
String
Text
Url
Choice
OpenChoice
Attachment
Reference ☑️
Quantity

支持的额外功能

  1. 支持enableWhen
  2. 支持enableBehavior
  3. 支持计算表达式(Calculated Expression)

如何使用

只需将QuestionnaireView小部件添加到您的小部件树中,即可拥有您的问卷UI。

QuestionnaireView(
    questionnaire: questionnaire, // FHIR R4 Questionnaire实例
    onAttachmentLoaded: onAttachmentLoaded, // 处理附件加载的回调
    locale: locale, // 按钮和验证文本的语言
    localizations: localizations, // 添加额外本地化的支持
    isLoading: loading, // 是否有正在进行的操作
    onSubmit: onSubmit, // 用户提交后的回调
    controller: controller, // 用于项目的视图和响应生成的控制器
)

QuestionnaireView

参数说明

  1. Questionnaire questionnaire: QuestionnaireView 需要一个类型为 Questionnaire 的对象。这是问卷的定义,将用于构建表单UI并生成问题和答案。
  2. String? locale: 可选参数,可以指定您希望使用的语言,例如 “es” 或 “en” 或 “fr” 等。默认情况下,系统语言将被使用。
  3. List<QuestionnaireBaseLocalization> localizations: 这是一个列表,允许您向问卷添加额外的语言翻译。当前包支持英语和西班牙语,您可以添加其他语言。您只需要为每个新语言创建一个类并扩展 QuestionnaireBaseLocalization
  4. QuestionnaireBaseLocalization? defaultLocalization: 如果指定的语言或系统语言不受支持,这表示应使用什么作为回退本地化。默认情况下,英语是回退语言。
  5. bool isLoading: 使用此参数来指示是否有正在进行的操作。例如,如果您需要进行API请求以加载您的问卷,您可以设置 isLoading = true,因此 QuestionnaireView 将显示一个闪烁的加载效果视图。
  6. Future<Attachment?> Function()? onAttachmentLoaded: 为了使该包更简单并兼容所有受支持的Flutter平台,加载附件的功能已委托给应用程序。因此,您需要通过实现此函数并返回一个 Attachment 实例(根据FHIR规范)来处理此逻辑。
  7. ValueChanged: 这是在用户点击提交按钮后触发的回调,您将得到一个 QuestionnaireResponse 实例。您只需设置主题或其他您认为必要的额外数据,但答案将被覆盖。
  8. QuestionnaireController? controller: 这是在 QuestionnaireView 中用于项目的视图和响应生成的控制器。这里的目的在于允许您使用 QuestionnaireController 扩展的一个实例,以便您可以覆盖行为和小部件。

一些额外注意事项

  1. 此小部件将使用应用程序的主题来构建,因此如果您想更改颜色、输入装饰等,只需在您的应用程序主题中更改即可。此外,包中的所有小部件都是公开的,可以暴露出来,如果有必要,您可以覆盖它们。
  2. QuestionnaireView 实现会根据每个 QuestionnaireItem 的定义来处理验证。
  3. 请检查示例项目,它展示了所有功能的实际应用。

示例

以下是示例代码:

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.dart';
import 'package:fhir_questionnaire/fhir_questionnaire.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

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

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

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

  [@override](/user/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(),
          theme: ThemeData(
              colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
              useMaterial3: true,
              inputDecorationTheme: snapshot.data),
          home: const MyHomePage(),
        );
      },
    );
  }
}

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

  [@override](/user/override)
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final List<({String name, String? value})> locales = [
    (name: 'System default', value: null),
    (name: 'English', value: 'en'),
    (name: 'Spanish', value: 'es'),
    (name: 'French', value: 'fr'),
  ];
  final List<({String name, int value})> questionnaires = [
    (name: 'Generic', value: 0),
    (name: 'PRAPARE', value: 1),
    (name: 'PHQ-9', value: 2),
    (name: 'GAD-7', value: 3),
    (name: 'BMI', value: 4),
  ];
  final List<({String name, InputDecorationTheme? value})> inputDecorationThemes = [
    (name: 'Default', value: null),
    (
      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,
      )
    ),
  ];
  String? selectedLocale;
  int selectedQuestionnaire = 0;
  InputDecorationTheme? selectedInputDecorationTheme;
  final extraLocalizations = [QuestionnaireFrLocalization()];

  [@override](/user/override)
  Widget build(BuildContext 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<int>(
                  decoration: const InputDecoration(
                      label: Text('Select a Questionnaire sample')),
                  value: selectedQuestionnaire,
                  items: questionnaires
                      .map((e) => DropdownMenuItem<int>(
                            value: e.value,
                            child: Text(e.name),
                          ))
                      .toList(),
                  onChanged: (value) {
                    if (value != null) {
                      selectedQuestionnaire = value;
                    }
                  }),
              const SizedBox(height: 16.0),
              DropdownButtonFormField<String?>(
                  decoration: const InputDecoration(
                      label: Text('Select the Questionnaire locale')),
                  value: selectedLocale,
                  items: locales
                      .map((e) => DropdownMenuItem<String>(
                            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 ')),
                  value: 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(switch (selectedQuestionnaire) {
        1 => QuestionnaireSamples.samplePrapare,
        2 => QuestionnaireSamples.samplePHQ9,
        3 => QuestionnaireSamples.sampleGAD7,
        4 => QuestionnaireSamples.sampleBMI,
        0 || _ => QuestionnaireSamples.sampleGeneric,
      });
}

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

  [@override](/user/override)
  State<StatefulWidget> createState() => QuestionnairePageState();
}

class QuestionnairePageState extends State<QuestionnairePage> {
  bool loading = true;

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

  [@override](/user/override)
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Questionnaire'),
      ),
      body: QuestionnaireView(
        key: ValueKey(loading),
        questionnaire: widget.questionnaire,
        onAttachmentLoaded: onAttachmentLoaded,
        locale: widget.locale,
        localizations: widget.localizations,
        isLoading: loading,
        onSubmit: onSubmit,
      ),
    );
  }

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

  void onSubmit(QuestionnaireResponse questionnaireResponse) async {
    Navigator.pop(context);
    var prettyString = const JsonEncoder.withIndent('  ')
        .convert(questionnaireResponse.toJson());

    debugPrint('''
      ========================================================================
      $prettyString
      ========================================================================
      ''');
  }
}

/// 法语本地化
class QuestionnaireFrLocalization extends QuestionnaireBaseLocalization {
  QuestionnaireFrLocalization() : super('fr');

  [@override](/user/override)
  String get btnSubmit => 'Soumettre';
  [@override](/user/override)
  String get btnUpload => 'Télécharger';
  [@override](/user/override)
  String get btnChange => 'Changement';
  [@override](/user/override)
  String get btnRemove => 'Retirer';
  [@override](/user/override)
  String get textOtherOption => 'Autre option';
  [@override](/user/override)
  String get textDate => 'Date';
  [@override](/user/override)
  String get textTime => 'Temps';
  [@override](/user/override)
  String get exceptionNoEmptyField => 'Ce champ est obligatoire.';
  [@override](/user/override)
  String get exceptionValueMustBeAPositiveIntegerNumber =>
      'La valeur doit être un nombre entier positif.';
  [@override](/user/override)
  String get exceptionValueMustBeAPositiveNumber =>
      'La valeur doit être un nombre positif.';
  [@override](/user/override)
  String get exceptionInvalidUrl => 'Invalid url.';
  [@override](/user/override)
  String exceptionValueOutOfRange(dynamic minValue, dynamic maxValue) =>
      'La valeur doit être comprise entre $minValue et $maxValue.';
  [@override](/user/override)
  String exceptionTextLength(dynamic minLength, dynamic maxLength) =>
      'Le texte doit contenir au moins des caractères $minLength et au maximum $maxLength.';
  [@override](/user/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();

  // 覆盖行为方法和获取器,如dragDevices
  [@override](/user/override)
  Set<PointerDeviceKind> get dragDevices => PointerDeviceKind.values.toSet();

  [@override](/user/override)
  ScrollPhysics getScrollPhysics(BuildContext context) =>
      kIsWeb ? _webScrollPhysics : super.getScrollPhysics(context);
}

更多关于Flutter FHIR问卷处理插件fhir_questionnaire的使用的实战系列教程也可以访问 https://www.itying.com/category-92-b0.html

1 回复

更多关于Flutter FHIR问卷处理插件fhir_questionnaire的使用的实战系列教程也可以访问 https://www.itying.com/category-92-b0.html


在处理Flutter应用中的FHIR问卷时,fhir_questionnaire插件是一个非常有用的工具。这个插件允许你根据FHIR(Fast Healthcare Interoperability Resources)标准创建和管理问卷。以下是一个如何使用fhir_questionnaire插件的基本示例代码,展示了如何加载问卷、渲染问卷以及收集用户输入。

首先,确保你已经在pubspec.yaml文件中添加了fhirfhir_questionnaire依赖:

dependencies:
  flutter:
    sdk: flutter
  fhir: ^x.y.z  # 替换为最新版本号
  fhir_questionnaire: ^x.y.z  # 替换为最新版本号

然后,运行flutter pub get来安装依赖。

接下来,是一个简单的Flutter应用示例,展示了如何使用fhir_questionnaire插件:

import 'package:flutter/material.dart';
import 'package:fhir/r4.dart' as fhir;
import 'package:fhir_questionnaire/fhir_questionnaire.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter FHIR Questionnaire Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: QuestionnaireScreen(),
    );
  }
}

class QuestionnaireScreen extends StatefulWidget {
  @override
  _QuestionnaireScreenState createState() => _QuestionnaireScreenState();
}

class _QuestionnaireScreenState extends State<QuestionnaireScreen> {
  late fhir.Questionnaire questionnaire;
  late QuestionnaireResponseController controller;

  @override
  void initState() {
    super.initState();
    // 加载或定义你的FHIR问卷
    questionnaire = fhir.Questionnaire.fromJson({
      'resourceType': 'Questionnaire',
      'id': 'example',
      'status': 'active',
      'item': [
        {
          'linkId': '1',
          'text': 'What is your name?',
          'type': 'string',
        },
        {
          'linkId': '2',
          'text': 'How old are you?',
          'type': 'integer',
        },
        // 添加更多问题...
      ],
    });

    // 初始化控制器
    controller = QuestionnaireResponseController(questionnaire);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('FHIR Questionnaire Demo'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: QuestionnaireWidget(
          questionnaire: questionnaire,
          controller: controller,
          onSubmit: (response) {
            // 处理提交的问卷响应
            print('Submitted response: $response');
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // 提交问卷
          controller.submit();
        },
        tooltip: 'Submit',
        child: Icon(Icons.send),
      ),
    );
  }
}

在这个示例中:

  1. 定义问卷:在initState方法中,我们定义了一个简单的FHIR问卷,包含两个问题:一个是字符串类型(名字),另一个是整数类型(年龄)。

  2. 初始化控制器:使用QuestionnaireResponseController来管理问卷的响应。

  3. 渲染问卷:使用QuestionnaireWidget来渲染问卷,并传入问卷定义和控制器。

  4. 提交问卷:当用户点击浮动按钮时,调用controller.submit()方法提交问卷,并在onSubmit回调中处理提交的响应。

请注意,这个示例仅展示了基本的问卷创建和提交流程。在实际应用中,你可能需要从服务器加载问卷定义,处理更复杂的问卷逻辑,以及将问卷响应存储到后端服务中。fhirfhir_questionnaire插件提供了丰富的API来满足这些需求,你可以查阅它们的文档以了解更多高级用法。

回到顶部