diff --git a/.mailmap b/.mailmap new file mode 100644 index 00000000..9d8d092f --- /dev/null +++ b/.mailmap @@ -0,0 +1,3 @@ +Charles Richardson charlieforward9 +Charles Richardson charlieforward9 <62311337+charlieforward9@users.noreply.github.com> +Charles Richardson Charles Richardson <62311337+charlieforward9@users.noreply.github.com> \ No newline at end of file diff --git a/amplify/backend/api/visualpt/cli-inputs.json b/amplify/backend/api/visualpt/cli-inputs.json index d1c5138e..cbbf8376 100644 --- a/amplify/backend/api/visualpt/cli-inputs.json +++ b/amplify/backend/api/visualpt/cli-inputs.json @@ -4,17 +4,12 @@ "apiName": "visualpt", "serviceName": "AppSync", "defaultAuthType": { - "mode": "API_KEY", - "keyDescription": "api key description", - "expirationTime": 30 + "mode": "AMAZON_COGNITO_USER_POOLS", + "cognitoUserPoolId": "authvisualpt" }, "additionalAuthTypes": [ { "mode": "AWS_IAM" - }, - { - "mode": "AMAZON_COGNITO_USER_POOLS", - "cognitoUserPoolId": "authvisualpt" } ], "conflictResolution": { diff --git a/amplify/backend/api/visualpt/parameters.json b/amplify/backend/api/visualpt/parameters.json index a551f8d0..ae9b31c6 100644 --- a/amplify/backend/api/visualpt/parameters.json +++ b/amplify/backend/api/visualpt/parameters.json @@ -8,5 +8,5 @@ "Outputs.UserPoolId" ] }, - "AuthModeLastUpdated": "2024-05-05T02:49:18.520Z" + "AuthModeLastUpdated": "2024-09-18T03:43:56.263Z" } \ No newline at end of file diff --git a/amplify/backend/api/visualpt/schema.graphql b/amplify/backend/api/visualpt/schema.graphql index a15a28a2..5ad01ede 100644 --- a/amplify/backend/api/visualpt/schema.graphql +++ b/amplify/backend/api/visualpt/schema.graphql @@ -13,15 +13,31 @@ enum Gender { FEMALE } +enum AgeRange { + BABY_0_5 + CHILD_6_12 + TEEN_13_19 + ADULT_20_39 + ADULT_40_64 + SENIOR_65_69 + SENIOR_70_79 + SENIOR_80_84 + SENIOR_85_89 +} + enum AssessmentType { CTSIB + MCTSIB GAIT BERG REACH FIST + DEV } -type User @model @auth(rules: [{ allow: public }]) { +type User + @model + @auth(rules: [{ allow: groups, groups: ["Admin"] }, { allow: owner }]) { id: ID! email: AWSEmail! first_name: String! @@ -30,7 +46,9 @@ type User @model @auth(rules: [{ allow: public }]) { Subjects: [Subject!] @hasMany(indexName: "byUser", fields: ["id"]) } -type Subject @model @auth(rules: [{ allow: owner }]) { +type Subject + @model + @auth(rules: [{ allow: groups, groups: ["Admin"] }, { allow: owner }]) { id: ID! userID: ID! @index(name: "byUser") first_name: String! @@ -43,7 +61,9 @@ type Subject @model @auth(rules: [{ allow: owner }]) { Assessments: [Assessment!] @hasMany(indexName: "bySubject", fields: ["id"]) } -type Assessment @model @auth(rules: [{ allow: owner }]) { +type Assessment + @model + @auth(rules: [{ allow: groups, groups: ["Admin"] }, { allow: owner }]) { id: ID! subjectID: ID! @index(name: "bySubject") type: AssessmentType! @@ -53,7 +73,9 @@ type Assessment @model @auth(rules: [{ allow: owner }]) { Conditions: [Condition!] @hasMany(indexName: "byAssessment", fields: ["id"]) } -type Condition @model @auth(rules: [{ allow: owner }]) { +type Condition + @model + @auth(rules: [{ allow: groups, groups: ["Admin"] }, { allow: owner }]) { id: ID! assessmentID: ID! @index(name: "byAssessment") sequenceNumber: Int! @@ -61,7 +83,9 @@ type Condition @model @auth(rules: [{ allow: owner }]) { trials: [Trial!] @hasMany(indexName: "byCondition", fields: ["id"]) } -type Trial @model @auth(rules: [{ allow: owner }]) { +type Trial + @model + @auth(rules: [{ allow: groups, groups: ["Admin"] }, { allow: owner }]) { id: ID! conditionID: ID! @index(name: "byCondition") start_time: AWSDateTime! @@ -69,6 +93,16 @@ type Trial @model @auth(rules: [{ allow: owner }]) { Detections: [Detection!] } +type Norm @model @auth(rules: [{ allow: private }]) { + id: ID! + type: AssessmentType! + sequenceNumber: Int! + age_range: AgeRange! + gender: Gender! + score_sum: Float! + trial_sum: Int! +} + type Detection { id: String! version: Int! diff --git a/amplify/backend/backend-config.json b/amplify/backend/backend-config.json index 50cfb2b5..6640164c 100644 --- a/amplify/backend/backend-config.json +++ b/amplify/backend/backend-config.json @@ -15,20 +15,13 @@ "additionalAuthenticationProviders": [ { "authenticationType": "AWS_IAM" - }, - { - "authenticationType": "AMAZON_COGNITO_USER_POOLS", - "userPoolConfig": { - "userPoolId": "authvisualpt" - } } ], "defaultAuthentication": { - "apiKeyConfig": { - "apiKeyExpirationDays": 30, - "description": "api key description" - }, - "authenticationType": "API_KEY" + "authenticationType": "AMAZON_COGNITO_USER_POOLS", + "userPoolConfig": { + "userPoolId": "authvisualpt" + } } } }, diff --git a/amplify/backend/types/amplify-dependent-resources-ref.d.ts b/amplify/backend/types/amplify-dependent-resources-ref.d.ts index caa45bdc..adec2444 100644 --- a/amplify/backend/types/amplify-dependent-resources-ref.d.ts +++ b/amplify/backend/types/amplify-dependent-resources-ref.d.ts @@ -2,8 +2,7 @@ export type AmplifyDependentResourcesAttributes = { "api": { "visualpt": { "GraphQLAPIEndpointOutput": "string", - "GraphQLAPIIdOutput": "string", - "GraphQLAPIKeyOutput": "string" + "GraphQLAPIIdOutput": "string" } }, "auth": { diff --git a/assets/images/vpt_appicon.png b/assets/images/vpt_appicon.png new file mode 100644 index 00000000..8c8ae974 Binary files /dev/null and b/assets/images/vpt_appicon.png differ diff --git a/assets/models/pose_classification.tflite b/assets/models/pose_classification.tflite index 621e0507..5d87e61a 100644 Binary files a/assets/models/pose_classification.tflite and b/assets/models/pose_classification.tflite differ diff --git a/lib/amplifyconfiguration.dart b/lib/amplifyconfiguration.dart index d5d78806..639f8260 100644 --- a/lib/amplifyconfiguration.dart +++ b/lib/amplifyconfiguration.dart @@ -8,8 +8,7 @@ const amplifyconfig = '''{ "endpointType": "GraphQL", "endpoint": "https://pzdub2f2czghti3zlrepb5awv4.appsync-api.us-east-1.amazonaws.com/graphql", "region": "us-east-1", - "authorizationType": "API_KEY", - "apiKey": "da2-uj6vhfa3mna7hfwciugu7juooq" + "authorizationType": "AMAZON_COGNITO_USER_POOLS" } } } @@ -26,21 +25,14 @@ const amplifyconfig = '''{ "Default": { "ApiUrl": "https://pzdub2f2czghti3zlrepb5awv4.appsync-api.us-east-1.amazonaws.com/graphql", "Region": "us-east-1", - "AuthMode": "API_KEY", - "ApiKey": "da2-uj6vhfa3mna7hfwciugu7juooq", - "ClientDatabasePrefix": "visualpt_API_KEY" + "AuthMode": "AMAZON_COGNITO_USER_POOLS", + "ClientDatabasePrefix": "visualpt_AMAZON_COGNITO_USER_POOLS" }, "visualpt_AWS_IAM": { "ApiUrl": "https://pzdub2f2czghti3zlrepb5awv4.appsync-api.us-east-1.amazonaws.com/graphql", "Region": "us-east-1", "AuthMode": "AWS_IAM", "ClientDatabasePrefix": "visualpt_AWS_IAM" - }, - "visualpt_AMAZON_COGNITO_USER_POOLS": { - "ApiUrl": "https://pzdub2f2czghti3zlrepb5awv4.appsync-api.us-east-1.amazonaws.com/graphql", - "Region": "us-east-1", - "AuthMode": "AMAZON_COGNITO_USER_POOLS", - "ClientDatabasePrefix": "visualpt_AMAZON_COGNITO_USER_POOLS" } }, "CredentialsProvider": { diff --git a/lib/core/services/repository.dart b/lib/core/services/repository.dart index a6e44f0c..1a02a4da 100644 --- a/lib/core/services/repository.dart +++ b/lib/core/services/repository.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:visualpt/features/assessment/types.dart'; abstract class Repository { - final AssessmentMeta meta; + final TrialMeta meta; final StreamController _streamController; var isInitialized = false; var count = 0; @@ -24,6 +24,10 @@ abstract class Repository { Future runPredict(String id, I input, Map? params); Future store( String id, I input, O output, Map? params); + void reset() { + localStorage.clear(); + count = 0; + } void dispose() { _streamController.close(); diff --git a/lib/core/views/base_view.dart b/lib/core/views/base_view.dart index 234d32aa..4bd1ea94 100644 --- a/lib/core/views/base_view.dart +++ b/lib/core/views/base_view.dart @@ -5,10 +5,15 @@ import 'package:visualpt/features/assessment/view/components/assessment_instruct class BaseView extends StatefulWidget { const BaseView( - {super.key, required this.child, this.instructions, this.pageTitle}); + {super.key, + required this.child, + this.instructions, + this.pageTitle, + this.poppable = true}); final Widget child; final AssessmentInstructions? instructions; final String? pageTitle; + final bool poppable; @override State createState() => _BaseViewState(); @@ -17,76 +22,95 @@ class BaseView extends StatefulWidget { class _BaseViewState extends State { bool showInstructions = true; + @override + void initState() { + super.initState(); + setState(() { + showInstructions = widget.instructions == null ? false : true; + }); + } + @override void didUpdateWidget(BaseView oldWidget) { super.didUpdateWidget(oldWidget); setState(() => showInstructions = true); } + bool _canPop() { + if (!widget.poppable || showInstructions) { + return false; + } + return true; // Allow navigation otherwise + } + @override Widget build(BuildContext context) { final screenSize = MediaQuery.of(context).size; - return Stack(alignment: Alignment.center, children: [ - CupertinoPageScaffold( - navigationBar: CupertinoNavigationBar( - backgroundColor: Styles.backgroundAccent, - middle: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.75), - child: widget.pageTitle == null - ? const Image( - image: AssetImage(Styles.mainLogoPath), - fit: BoxFit.fitWidth) - : Text( - widget.pageTitle!, - style: Styles.heading3, - textAlign: TextAlign.center, - )), - trailing: widget.pageTitle == null - ? GestureDetector( - onTap: () => Navigator.pushNamed(context, "/settings"), - child: const Icon( - CupertinoIcons.settings, - color: Styles.black, - )) - : widget.instructions != null - ? GestureDetector( - onTap: () => setState( - () => showInstructions = !showInstructions), - child: const Icon( - CupertinoIcons.info, - color: Styles.black, - )) - : null, - ), - child: SizedBox( - height: screenSize.height, - width: screenSize.width, - child: Stack(alignment: Alignment.center, children: [ - const ViewBackground(), - SafeArea( - child: widget.child, - ) - ]), - )), - //InstructionModal below - showInstructions && widget.instructions != null - ? Container( - alignment: Alignment.center, + return PopScope( + canPop: _canPop(), // Override back button behavior + child: Stack(alignment: Alignment.center, children: [ + CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + automaticallyImplyLeading: _canPop() ? true : false, + backgroundColor: Styles.backgroundAccent, + middle: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.75), + child: widget.pageTitle == null + ? const Image( + image: AssetImage(Styles.mainLogoPath), + fit: BoxFit.fitWidth) + : Text( + widget.pageTitle!, + style: Styles.heading3, + textAlign: TextAlign.center, + )), + trailing: widget.pageTitle == null + ? GestureDetector( + onTap: () => Navigator.pushNamed(context, "/settings"), + child: const Icon( + CupertinoIcons.settings, + color: Styles.black, + )) + : widget.instructions != null + ? GestureDetector( + onTap: () => setState( + () => showInstructions = !showInstructions), + child: const Icon( + CupertinoIcons.info, + color: Styles.black, + )) + : null, + ), + child: SizedBox( height: screenSize.height, width: screenSize.width, - color: Styles.black.withOpacity(0.5), - child: GestureDetector( - child: Container( - height: screenSize.height * 0.8, - width: screenSize.width * 0.8, - decoration: const BoxDecoration( - borderRadius: Styles.borderRadiusM, - color: Styles.ceruleanBlue), - child: widget.instructions), - onTap: () => setState(() => showInstructions = false))) - : Container() - ]); + child: Stack(alignment: Alignment.center, children: [ + const ViewBackground(), + SafeArea( + child: widget.child, + ) + ]), + )), + // InstructionModal below + showInstructions && widget.instructions != null + ? Container( + alignment: Alignment.center, + height: screenSize.height, + width: screenSize.width, + color: Styles.black.withOpacity(0.5), + child: GestureDetector( + child: Container( + height: screenSize.height * 0.9, + width: screenSize.width * 0.9, + decoration: const BoxDecoration( + borderRadius: Styles.borderRadiusM, + color: Styles.ceruleanBlue), + child: widget.instructions), + onTap: () => setState(() => showInstructions = false))) + : Container() + ]), + ); } } diff --git a/lib/core/views/components/app_loading.dart b/lib/core/views/components/app_loading.dart index ce5d052f..27c1d11e 100644 --- a/lib/core/views/components/app_loading.dart +++ b/lib/core/views/components/app_loading.dart @@ -10,68 +10,19 @@ class AppLoading extends StatefulWidget { } class AppLoadingState extends State with TickerProviderStateMixin { - late AnimationController breathingController; - double breathe = 0.0; - - @override - void initState() { - super.initState(); - - breathingController = AnimationController( - vsync: this, duration: const Duration(milliseconds: 1000)); - breathingController.addStatusListener((status) { - if (status == AnimationStatus.completed) { - breathingController.reverse(); - } else if (status == AnimationStatus.dismissed) { - breathingController.forward(); - } - }); - - breathingController.addListener(() { - setState(() { - breathe = breathingController.value; - }); - }); - breathingController.forward(); - } - - @override - void dispose() { - breathingController.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { - final size = 500.0 - 400.0 * breathe; return Container( height: MediaQuery.of(context).size.height, width: MediaQuery.of(context).size.width, color: Styles.backgroundPrimary, - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient( - center: Alignment.center, - colors: [ - Styles.backgroundAccent.withOpacity(1.0), - Styles.backgroundAccent.withOpacity(0.8), - Styles.backgroundAccent.withOpacity(0.6), - Styles.backgroundAccent.withOpacity(0.4), - Styles.backgroundAccent.withOpacity(0.2), - Styles.backgroundAccent.withOpacity(0.0), - ], - ), - ), - height: size, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Image( - image: AssetImage(Styles.mainLogoPath), fit: BoxFit.contain), - Text(widget.message != null ? widget.message! : "Loading...") - ], - ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Image( + image: AssetImage(Styles.mainLogoPath), fit: BoxFit.contain), + Text(widget.message != null ? widget.message! : "Loading...") + ], ), ); } diff --git a/lib/core/views/home_view.dart b/lib/core/views/home_view.dart index e097890d..8d87c17f 100644 --- a/lib/core/views/home_view.dart +++ b/lib/core/views/home_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:visualpt/features/assessment/config/_config.dart'; -import 'package:visualpt/features/assessment/config/ctsib_collect/ctsib_collect.dart'; +import 'package:visualpt/features/assessment/config/dev/dev.dart'; import 'package:visualpt/features/assessment/config/mctsib/mctsib.dart'; import 'package:visualpt/features/assessment/view/components/assessment_card.dart'; import 'package:visualpt/core/views/_views.dart'; @@ -32,8 +33,8 @@ class HomeView extends StatelessWidget { ), AssessmentCard(CTSIB()), AssessmentCard(MCTSIB()), - AssessmentCard(CTSIB_COLLECT()), AssessmentCard(BERG()), + kDebugMode ? AssessmentCard(DEV()) : Container(), //TODO: AssessmentCard(GAIT()), // AssessmentCard(REACH()), // AssessmentCard(FIST()) diff --git a/lib/features/analysis/bloc/pdf_bloc.dart b/lib/features/analysis/bloc/pdf_bloc.dart index 7e5a6204..5316b3a3 100644 --- a/lib/features/analysis/bloc/pdf_bloc.dart +++ b/lib/features/analysis/bloc/pdf_bloc.dart @@ -2,13 +2,12 @@ import 'dart:async'; import 'dart:developer'; import 'dart:typed_data'; import 'package:flutter/cupertino.dart'; - import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:visualpt/features/assessment/config/ctsib/pdf.dart'; import 'package:visualpt/models/ModelProvider.dart'; -import 'package:visualpt/features/assessment/config/ctsib_collect/pdf.dart'; import 'package:visualpt/features/assessment/config/mctsib/pdf.dart'; -import 'package:visualpt/features/assessment/config/ctsib/pdf.dart'; +import 'package:visualpt/features/assessment/config/dev/pdf.dart'; import 'package:visualpt/features/assessment/config/gait/pdf.dart'; import 'package:visualpt/features/assessment/config/_pdf.dart'; @@ -16,13 +15,13 @@ part 'pdf_event.dart'; part 'pdf_state.dart'; class PdfBloc extends Bloc { - PdfBloc() : super(PdfLoading()) { + PdfBloc() : super(const PdfLoading()) { on(_onInit); } FutureOr _onInit(PdfInit event, Emitter emit) async { late PDF document; - emit(PdfLoading()); + emit(const PdfLoading()); switch (event.assessment.type) { case AssessmentType.CTSIB: document = CtsibPDF(); @@ -33,10 +32,11 @@ class PdfBloc extends Bloc { case AssessmentType.MCTSIB: document = MctsibPDF(); break; - case AssessmentType.CTSIB_COLLECT: - document = CtsibCollectPDF(); - break; + case AssessmentType.DEV: + document = DevPDF(); default: + throw Exception( + 'Invalid Assessment Type, Cannot Generate PDF for ${event.assessment.type}'); } try { await document @@ -49,12 +49,11 @@ class PdfBloc extends Bloc { assessment: event.assessment)); }); } on Exception catch (error) { + log(error.toString(), name: 'PDF Error'); emit(PdfError(exception: error)); } catch (e, stackTrace) { - emit(PdfError(exception: Exception(e.toString()))); log(e.toString(), stackTrace: stackTrace, name: 'PDF Error'); + emit(PdfError(exception: Exception(e.toString()))); } } - - bool viewCreated = false; } diff --git a/lib/features/analysis/bloc/pdf_state.dart b/lib/features/analysis/bloc/pdf_state.dart index ae76ad3b..cd108f58 100644 --- a/lib/features/analysis/bloc/pdf_state.dart +++ b/lib/features/analysis/bloc/pdf_state.dart @@ -1,19 +1,35 @@ part of 'pdf_bloc.dart'; @immutable -abstract class PdfState {} +abstract class PdfState extends Equatable { + const PdfState(); -class PdfLoading extends PdfState {} + @override + List get props => []; +} + +class PdfLoading extends PdfState { + const PdfLoading(); + + @override + List get props => []; +} class PdfReady extends PdfState { final Uint8List pdfData; final Subject subject; final Assessment assessment; - PdfReady( + const PdfReady( {required this.pdfData, required this.subject, required this.assessment}); + + @override + List get props => [pdfData, subject, assessment]; } class PdfError extends PdfState { final Exception exception; - PdfError({required this.exception}); + const PdfError({required this.exception}); + + @override + List get props => [exception]; } diff --git a/lib/features/analysis/view/analysis_view.dart b/lib/features/analysis/view/analysis_view.dart index 823f1a38..e4e7b33a 100644 --- a/lib/features/analysis/view/analysis_view.dart +++ b/lib/features/analysis/view/analysis_view.dart @@ -9,7 +9,6 @@ import 'package:visualpt/features/analysis/view/analysis_presenter.dart'; import 'package:visualpt/features/assessment/bloc/assessment_bloc.dart'; import 'package:visualpt/features/assessment/view/_assessments.dart'; import 'package:visualpt/features/email/bloc/email_bloc.dart'; -import 'package:visualpt/features/video/service/video_service.dart'; import 'package:visualpt/models/ModelProvider.dart'; import 'package:visualpt/features/assessment/service/assessment_service.dart'; import 'package:visualpt/core/views/_views.dart'; @@ -23,7 +22,8 @@ class AnalysisView extends StatelessWidget { @override Widget build(BuildContext context) { return BaseView( - pageTitle: "${asmtState.assessment.type.name} Report", + pageTitle: "${asmtState.meta.type.name} Report", + poppable: false, child: AnalysisPresenter( onCreate: PdfInit( subject: asmtState.subject, assessment: asmtState.dbAssessment), @@ -39,13 +39,20 @@ class AnalysisView extends StatelessWidget { AspectRatio( aspectRatio: 8.5 / 11, child: PDFView( - key: Key(state.pdfData.hashCode.toString()), + // key: Key(state.pdfData.hashCode.toString()), pdfData: state.pdfData, enableSwipe: false, pageFling: false, - onError: (error) => print("PDFView Error: $error"), - onPageError: (page, error) => - print("PDFView Error: $page $error"), + onError: (error) => { + BlocProvider.of(context).add(PdfInit( + subject: asmtState.subject, + assessment: asmtState.dbAssessment)) + }, + onPageError: (page, error) => { + BlocProvider.of(context).add(PdfInit( + subject: asmtState.subject, + assessment: asmtState.dbAssessment)) + }, pageSnap: false), ), Padding( @@ -57,8 +64,8 @@ class AnalysisView extends StatelessWidget { color: Styles.actionSecondary, onPressed: () { //TODO Add an are you sure popup - BlocProvider.of(context) - .add(AssessmentInit(asmtState.subject, reset: true)); + BlocProvider.of(context).add( + AssessmentInit(asmtState.subject, asmtState.storeData)); }, child: const Text("Redo Assessment", style: TextStyle(color: Styles.black)), @@ -91,7 +98,9 @@ class AnalysisView extends StatelessWidget { } Widget pdfLoading(BuildContext _, PdfState __) { - return const AppLoading(); + return const AppLoading( + message: "Generating PDF report...", + ); } Widget pdfError(BuildContext context, PdfState _, Exception e) { @@ -110,27 +119,32 @@ class AnalysisView extends StatelessWidget { ///The button that initates the email post ///Saves the PDF and stores assessment by default - Future onSend( + void onSend( BuildContext context, Subject subject, Assessment assessment, Uint8List pdf, - ) async { - final videos = await VideoService.getVideosInBase64(assessment); + ) { appDialog( context: context, - title: "Save and Send", - content: "Email will be sent to: ${subject.provider_contact}", + title: asmtState.storeData ? "Save and Send" : "Home", + content: asmtState.storeData + ? "This will assume the data is valid and change the age-group norm table with the data from this assessment. Email will be sent to: ${subject.provider_contact}" + : "This data was indicated as a test and will not be saved. Are you sure you want to go back?", secondaryCallback: () => Navigator.pop(context), primaryCallback: () { - assessment = AssessmentService().addPDF(assessment, pdf); - BlocProvider.of(context).add(EmailSend( - subject: subject, - assessment: assessment, - videos: videos, - onSuccess: () => AppNav(context).setHome(), - onFail: () => log("Email failed"))); - //TODO: BlocProvider.of(context).add(const PatientClear()); + if (asmtState.storeData) { + updateNorms(asmtState.meta, assessment, subject); + assessment = AssessmentService().addPDF(assessment, pdf); + BlocProvider.of(context).add(EmailSend( + subject: subject, + assessment: assessment, + onSuccess: () => AppNav(context).setHome(), + onFail: () => log("Email failed"))); + //TODO: BlocProvider.of(context).add(const PatientClear()); + } else { + AppNav(context).setHome(); + } }); } } diff --git a/lib/features/assessment/bloc/assessment_bloc.dart b/lib/features/assessment/bloc/assessment_bloc.dart index c4dea34f..713889e8 100644 --- a/lib/features/assessment/bloc/assessment_bloc.dart +++ b/lib/features/assessment/bloc/assessment_bloc.dart @@ -30,9 +30,7 @@ class AssessmentBloc FutureOr _onInit( AssessmentInit event, Emitter emit) async { - if (event.reset) { - assessmentStartTime = DateTime.now(); - } + assessmentStartTime = DateTime.now(); subject = event.subject; // amplify.Amplify.DataStore.save(subject); assessment = Assessment( @@ -50,12 +48,12 @@ class AssessmentBloc trials: null); // amplify.Amplify.DataStore.save(currentCondition!); emit(AssessmentActive( - getMeta(subject, config, 0, 0), subject, storeData, this.config)); + getMeta(subject, config, 0, 0), subject, event.storeData, this.config)); } FutureOr _onCondition( AssessmentCondition event, Emitter emit) async { - emit(AssessmentLoading(config, assessment.Conditions?.length ?? 0)); + emit(AssessmentLoading(assessment.Conditions?.length ?? 0, config)); currentCondition = currentCondition?.copyWith( duration: getElapsedSeconds(conditionStartTime)); // amplify.Amplify.DataStore.save(currentCondition!); @@ -157,9 +155,9 @@ double getElapsedSeconds(DateTime start) { return DateTime.now().difference(start).inMilliseconds / 1000; } -AssessmentMeta getMeta(Subject subject, BaseAssessment config, - int conditionIndex, int trialIndex) { - return AssessmentMeta( +TrialMeta getMeta(Subject subject, BaseAssessment config, int conditionIndex, + int trialIndex) { + return TrialMeta( subject.dob.toString(), subject.gender, subject.fall_history, diff --git a/lib/features/assessment/bloc/assessment_event.dart b/lib/features/assessment/bloc/assessment_event.dart index 0b1abd3e..2c123594 100644 --- a/lib/features/assessment/bloc/assessment_event.dart +++ b/lib/features/assessment/bloc/assessment_event.dart @@ -5,11 +5,11 @@ abstract class AssessmentEvent extends Equatable {} class AssessmentInit extends AssessmentEvent { final Subject subject; - final bool reset; - AssessmentInit(this.subject, {this.reset = false}); + final bool storeData; + AssessmentInit(this.subject, this.storeData); @override - List get props => [subject, reset]; + List get props => [subject, storeData]; } class AssessmentCondition extends AssessmentEvent { diff --git a/lib/features/assessment/bloc/assessment_state.dart b/lib/features/assessment/bloc/assessment_state.dart index e19bbf80..f4bded0d 100644 --- a/lib/features/assessment/bloc/assessment_state.dart +++ b/lib/features/assessment/bloc/assessment_state.dart @@ -2,25 +2,25 @@ part of 'assessment_bloc.dart'; @immutable abstract class AssessmentState extends Equatable { - final T assessment; - const AssessmentState(this.assessment); + final T meta; + const AssessmentState(this.meta); } class AssessmentForm extends AssessmentState { - const AssessmentForm(super.assessment); + const AssessmentForm(super.meta); @override - List get props => []; + List get props => [meta]; } class AssessmentActive extends AssessmentState { - final AssessmentMeta meta; + final TrialMeta trialMeta; final Subject subject; final bool storeData; const AssessmentActive( - this.meta, this.subject, this.storeData, super.assessment); + this.trialMeta, this.subject, this.storeData, super.meta); @override - List get props => [meta, assessment, storeData]; + List get props => [trialMeta, meta, storeData]; } class AssessmentAnalysis extends AssessmentState { @@ -28,25 +28,25 @@ class AssessmentAnalysis extends AssessmentState { final bool storeData; final Assessment dbAssessment; const AssessmentAnalysis( - this.subject, this.storeData, this.dbAssessment, super.assessment); + this.subject, this.storeData, this.dbAssessment, super.meta); @override - List get props => [assessment]; + List get props => [subject, storeData, dbAssessment, meta]; } class AssessmentLoading extends AssessmentState { final int index; - const AssessmentLoading(super.assessment, this.index); + const AssessmentLoading(this.index, super.meta); @override - List get props => [assessment, index]; + List get props => [meta, index]; } class AssessmentError extends AssessmentState { //TODO Establish VisualPT Error Codes final Exception? message; - const AssessmentError(this.message, super.assessment); + const AssessmentError(this.message, super.meta); @override - List get props => [message, assessment]; + List get props => [message, meta]; } diff --git a/lib/features/assessment/config/_pdf.dart b/lib/features/assessment/config/_pdf.dart index eff5df4d..87a9e54a 100644 --- a/lib/features/assessment/config/_pdf.dart +++ b/lib/features/assessment/config/_pdf.dart @@ -95,8 +95,8 @@ abstract class PDF { double remainingSeconds = durationInSeconds % 60; //If the duration is less than a minute, return only the seconds if (minutes == 0) { - return "${remainingSeconds.toStringAsFixed(2)} seconds"; + return "${remainingSeconds.round()} seconds"; } - return "$minutes minutes ${remainingSeconds.toStringAsFixed(2)} seconds"; + return "$minutes minutes ${remainingSeconds.round()} seconds"; } } diff --git a/lib/features/assessment/config/berg/berg.dart b/lib/features/assessment/config/berg/berg.dart index 9d2fe609..bd4d6407 100644 --- a/lib/features/assessment/config/berg/berg.dart +++ b/lib/features/assessment/config/berg/berg.dart @@ -1,3 +1,4 @@ +import 'package:amplify_core/src/types/temporal/temporal_date.dart'; import 'package:visualpt/features/assessment/view/_assessments.dart'; import 'package:visualpt/features/inference/constants/model_file.dart'; import 'package:visualpt/features/inference/service/pose_detection/pose_detection_output.dart'; @@ -26,6 +27,9 @@ class BERG implements BaseAssessment { @override int get trials => 1; + @override + int get trialDuration => 30; + @override List get conditionNames => [ "Standing Unsupported with Feet Together", @@ -63,6 +67,36 @@ class BERG implements BaseAssessment { @override bool get isAvailable => true; + + @override + Future?> getNorms(TemporalDate dob, Gender gender) { + // TODO: implement getNorm + throw UnimplementedError(); + } + + @override + Future setNorm( + TemporalDate dob, Gender gender, int seqNum, double score, int trials) { + // TODO: implement setNorm + throw UnimplementedError(); + } + + @override + List calculateScores(Assessment assessment) { + // TODO: implement calculateScores + throw UnimplementedError(); + } + + @override + String getNormativeMessage(Assessment assessment, List? norms) { + // TODO: implement getNormativeMessage + throw UnimplementedError(); + } + + @override + bool isComplete(args) { + return false; + } } class BERGData { diff --git a/lib/features/assessment/config/berg/pdf.dart b/lib/features/assessment/config/berg/pdf.dart index f497f9ea..f4972403 100644 --- a/lib/features/assessment/config/berg/pdf.dart +++ b/lib/features/assessment/config/berg/pdf.dart @@ -1,13 +1,12 @@ -import 'package:amplify_flutter/amplify_flutter.dart'; import 'package:flutter/services.dart'; import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart'; +import 'package:visualpt/features/assessment/config/berg/berg.dart'; import 'package:visualpt/models/ModelProvider.dart'; import 'package:visualpt/features/assessment/config/_pdf.dart'; -import 'package:visualpt/features/assessment/config/ctsib/ctsib.dart'; -class CtsibPDF extends PDF { - final baseAssessment = CTSIB(); +class BergPDF extends PDF { + final berg = BERG(); @override Future generatePdf(Subject subject, Assessment assessment) async { await configPdfStyles(); @@ -202,7 +201,7 @@ class CtsibPDF extends PDF { ), Padding( padding: const EdgeInsets.all(8.0), - child: Text(baseAssessment.conditionNames[i]), + child: Text(berg.conditionNames[i]), ), Padding( padding: const EdgeInsets.all(8.0), @@ -226,7 +225,7 @@ class CtsibPDF extends PDF { Padding( padding: const EdgeInsets.all(8.0), child: Text( - "${assessment.Conditions!.map((s) => s.duration).reduce((a, b) => a + b).toString()} / ${30 * baseAssessment.conditions} seconds", + "${assessment.Conditions!.map((s) => s.duration).reduce((a, b) => a + b).toString()} / ${30 * berg.conditions} seconds", ), ), ], @@ -265,18 +264,14 @@ class CtsibPDF extends PDF { } //TODO: Add normative data, and potientally another data field for collecting the patients fall history - Widget normativeData(Subject subject, Assessment assessment) { + Widget normativeData(Assessment assessment, List? norms) { return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ RichText( textAlign: TextAlign.left, text: TextSpan(text: "Normative Comparison", style: subHeader)), RichText( text: TextSpan( - text: getNormativeMessage( - assessment.Conditions! - .map((s) => s.duration) - .reduce((a, b) => a + b), - subject.dob), + text: berg.getNormativeMessage(assessment, norms), style: detailBold)), ]); } @@ -328,7 +323,3 @@ class CtsibPDF extends PDF { ); } } - -String getNormativeMessage(double totalDuration, TemporalDate dob) { - return "TODO"; -} diff --git a/lib/features/assessment/config/ctsib/ctsib.dart b/lib/features/assessment/config/ctsib/ctsib.dart index 221c405b..71d2bfec 100644 --- a/lib/features/assessment/config/ctsib/ctsib.dart +++ b/lib/features/assessment/config/ctsib/ctsib.dart @@ -1,4 +1,6 @@ import 'dart:math'; +import 'package:amplify_api/amplify_api.dart'; +import 'package:amplify_flutter/amplify_flutter.dart'; import 'package:visualpt/features/assessment/view/_assessments.dart'; import 'package:visualpt/features/inference/constants/model_file.dart'; import 'package:visualpt/features/inference/service/pose_classification/pose_class_output.dart'; @@ -29,6 +31,9 @@ class CTSIB implements BaseAssessment { @override int get trials => 1; + @override + int get trialDuration => 20; + @override List get conditionNames => [ "Eyes Open, Firm Surface", @@ -84,15 +89,178 @@ class CTSIB implements BaseAssessment { @override bool get isAvailable => true; - //Numbers are in m/s and start in the 6-12, then 13-19, then 20-29, 30-39... to 80+ - static const norms = [179.7, 180.0, 179.9, 177.7]; - static const ageGroups = [20, 49, 59, 69, 79]; + @override + Future?> getNorms(TemporalDate dob, Gender gender) async { + try { + final AgeRange ageRange = getAgeEnumByTemporalDate(dob); + + //Temporary solution till the DB is populated + final norm = createNormData() + .where((element) => + element.age_range == ageRange && element.gender == gender) + .toList() + ..sort((a, b) => a.sequenceNumber.compareTo(b.sequenceNumber)); + return norm; + //Temporary solution till the DB is populated + + final request = ModelQueries.list(Norm.classType, + where: Norm.TYPE + .eq(AssessmentType.CTSIB) + .and(Norm.AGE_RANGE.eq(ageRange).and(Norm.GENDER.eq(gender)))); + final response = await Amplify.API.query(request: request).response; + + final norms = response.data?.items; + if (norms == null || norms.isEmpty) { + return null; + } else { + return norms.map((e) => e as Norm).toList(); + } + } catch (e) { + print(e); + return null; + } + } + + @override + Future setNorm(TemporalDate dob, Gender gender, int seqNum, + double score, int trials) async { + try { + final existingNorms = await getNorms(dob, gender); + final existingNorm = existingNorms + ?.firstWhere((element) => element.sequenceNumber == seqNum); + + if (existingNorm != null) { + final request = ModelMutations.update(existingNorm.copyWith( + score_sum: existingNorm.score_sum + score, + trial_sum: existingNorm.trial_sum + trials, + )); + final response = await Amplify.API.mutate(request: request).response; + return response.data; + } else { + final newNorm = Norm( + type: AssessmentType.CTSIB, + age_range: getAgeEnumByTemporalDate(dob), + gender: gender, + sequenceNumber: seqNum, + score_sum: score, + trial_sum: trials, + ); + final request = ModelMutations.create(newNorm); + final response = await Amplify.API.mutate(request: request).response; + return response.data; + } + } catch (e) { + print(e); + return null; + } + } + + @override + List calculateScores(Assessment assessment) { + //Equilibrium Score is the the proportion of the actual sway to the swayLimit, multiplied by 100, summed up and divided by the number of detections per trial, then summed across all trials and divided by the number of trials + //If the sway detected is greater than the sway limit, the score is 0 + final validConditions = assessment.Conditions?.where((condition) => + condition.trials != null && condition.trials!.isNotEmpty) + .toList() ?? + []; + + try { + final conditionScores = validConditions.map((condition) { + final trialsCountAndScoreSum = condition.trials!.fold<(int, double)>( + (0, 0.0), (trialsCountAndScoreIterator, trial) { + if (trial.Detections == null || trial.Detections!.isEmpty) { + return trialsCountAndScoreIterator; + } + final detectionCountAndScoreSum = trial.Detections! + .fold<(int, double)>((0, 0.0), + (detectionCountAndScore, detection) { + //If a fall has been registered, in other words, a detection registered and the score 0 + if (detectionCountAndScore.$1 != 0 && + detectionCountAndScore.$2 == 0) { + //The score remains 0 + return detectionCountAndScore; + } + final (isFall, detectionScore) = + poseToCtsibData(detection.keypoints); + + if (isFall) { + return (detectionCountAndScore.$1 + 1, 0.0); + } else { + return ( + detectionCountAndScore.$1 + 1, + detectionCountAndScore.$2 + detectionScore + ); + } + }); + final trialScore = + detectionCountAndScoreSum.$2 / detectionCountAndScoreSum.$1; + + return ( + trialsCountAndScoreIterator.$1 + 1, + trialsCountAndScoreIterator.$2 + trialScore + ); + }); + final value = trialsCountAndScoreSum.$2 / trialsCountAndScoreSum.$1; + return value.isNaN ? 0.0 : value; + }).toList(); + return conditionScores; + } on Exception catch (e) { + print(e); + return List.generate(validConditions.length, (index) => 0.0, + growable: false); + } + } + + @override + String getNormativeMessage(Assessment assessment, List? norms) { + // Do not include the last value in the score list as it is the total score + final globalScores = norms + ?.map((s) => s.trial_sum == 0 || s.sequenceNumber == conditions + ? 0 + : s.score_sum / s.trial_sum) + .reduce((a, b) => (a) + (b)) ?? + 0.0; + + // Calculate local scores for the patient + final localScores = calculateScores(assessment).reduce((a, b) => a + b); + + // Calculate the percentage difference between patient and norm + final percent = ((localScores - globalScores) / globalScores) * 100; + + // Generate a clinically relevant message + String performanceComparison; + if (localScores > globalScores) { + performanceComparison = + "${percent.abs().toStringAsFixed(2)}% higher than the normative data."; + } else { + performanceComparison = + "${percent.abs().toStringAsFixed(2)}% lower than the normative data."; + } + + String riskMessage; + if (percent < -20) { + riskMessage = + "This score indicates a significantly increased risk for falls compared to age and gender norms."; + } else if (percent < 0) { + riskMessage = + "This performance is below norms but within an acceptable range, though caution is advised."; + } else { + riskMessage = "This is a healthy performance within the normative range."; + } - static double getAgeNorm(int age) { - final int index = ageGroups.indexWhere((element) => element > age); - return norms[index]; + return "This patient's performance is $performanceComparison $riskMessage"; } + @override + bool isComplete(args) { + final (isFall, __) = CTSIB.poseToCtsibData(args.toString()); + return isFall; + } + + //Numbers are in m/s and start in the 6-12, then 13-19, then 20-29, 30-39... to 80+ + // static const norms = [179.7, 180.0, 179.9, 177.7]; + // static const ageGroups = [20, 49, 59, 69, 79]; + static List<(double, double)> averageSwayPerCondition(Assessment assessment) { // Filter out null conditions and conditions without trials final validConditions = assessment.Conditions?.where((condition) => @@ -243,58 +411,84 @@ class CTSIB implements BaseAssessment { return classifications[mostFrequentIndex]?.toString() ?? "invalid"; } - static List getEquilibriumScores(Assessment assessment) { - //Equilibrium Score is the the proportion of the actual sway to the swayLimit, multiplied by 100, summed up and divided by the number of detections per trial, then summed across all trials and divided by the number of trials - final validConditions = assessment.Conditions?.where((condition) => - condition.trials != null && condition.trials!.isNotEmpty) - .toList() ?? - []; - - final conditionScores = validConditions.map((condition) { - final trialsCountAndScoreSum = condition.trials! - .fold<(int, double)>((0, 0.0), (trialsCountAndScoreIterator, trial) { - if (trial.Detections == null || trial.Detections!.isEmpty) { - return trialsCountAndScoreIterator; - } - final detectionCountAndScoreSum = trial.Detections!.fold<(int, double)>( - (0, 0.0), (detectionCountAndScoreIterator, detection) { - final poseOutput = - PoseDetectionOutput.fromString(detection.keypoints); - final (lat, lng) = PoseDetectionOutput.computeAngles( - poseOutput.map((e) => (e[0], e[1], e[2])).toList()); - final latProportion = lat / swayLimits[2]; - //If the subject is leaning forward, the angle will be negative, so use the anterior sway limit - final lonProportion = - lng > 0 ? lng / swayLimits[0] : lng / swayLimits[1]; - if (latProportion > 1 || lonProportion > 1) { - return ( - detectionCountAndScoreIterator.$1 + 1, - detectionCountAndScoreIterator.$2, - ); - } - final proportionOfFailure = - sqrt(pow(latProportion, 2) + pow(lonProportion, 2)); - final detectionScore = 100 * (1 - proportionOfFailure); - return ( - detectionCountAndScoreIterator.$1 + 1, - detectionCountAndScoreIterator.$2 + detectionScore - ); - }); - final trialScore = - detectionCountAndScoreSum.$2 / detectionCountAndScoreSum.$1; - - return ( - trialsCountAndScoreIterator.$1 + 1, - trialsCountAndScoreIterator.$2 + trialScore - ); - }); - - return trialsCountAndScoreSum.$2 / trialsCountAndScoreSum.$1; - }).toList(); - - return conditionScores; + //Returns isFall, equlibrium score + static (bool, double) poseToCtsibData(String keypoints) { + final poseOutput = PoseDetectionOutput.fromString(keypoints); + + final (xSway, zSway) = PoseDetectionOutput.computeAngles( + poseOutput.map((e) => (e[0], e[1], e[2])).toList()); + final latProportion = xSway / swayLimits[2]; + //If the subject is leaning forward, the angle will be negative, so use the anterior sway limit + // final lonProportion = + // zSway > 0 ? zSway / swayLimits[0] : zSway / swayLimits[1]; + //TODO: since the zSway is not to scale, the lonProportion will usually be greater than 1, usually leading to an equilibrium score of 0. + // A new pose detection model should be trained to fix this + if (latProportion > 1 /* || lonProportion > 1*/) { + //This indicates a fall + return (true, 0.0); + } + final proportionOfFailure = latProportion; + //sqrt(pow(latProportion, 2) + pow(lonProportion, 2)); + final detectionScore = 100 * (1 - proportionOfFailure); + return ( + false, //No fall + detectionScore, + ); } } //Anterior, Posterior, Lat const swayLimits = [8.0, 4.5, 8.0]; + +Map> normativeValues = { + AgeRange.BABY_0_5: [86.1, 83.4, 81.6, 86.1, 82.8, 82.1, 83.7], + AgeRange.CHILD_6_12: [86.1, 83.4, 81.6, 86.1, 82.8, 82.1, 83.7], + AgeRange.TEEN_13_19: [86.1, 83.4, 81.6, 86.1, 82.8, 82.1, 83.7], + AgeRange.ADULT_20_39: [86.1, 83.4, 81.6, 86.1, 82.8, 82.1, 83.7], + AgeRange.ADULT_40_64: [87.9, 85.3, 87.5, 83.4, 81.5, 78.9, 84.1], + AgeRange.SENIOR_65_69: [88.7, 85.5, 87.9, 80.4, 71.6, 65.5, 78.3], + AgeRange.SENIOR_70_79: [89.4, 85.8, 88.2, 77.6, 61.6, 53.0, 72.8], + AgeRange.SENIOR_80_84: [94.3, 91.1, 90.0, 77.2, 48.8, 47.7, 69.9], + AgeRange.SENIOR_85_89: [92.6, 89.9, 89.9, 71.2, 28.9, 33.2, 60.7], +}; + +List createNormData() { + List norms = []; + + // AssessmentType for this data + AssessmentType assessmentType = AssessmentType.CTSIB; + + // Create norm entries for each condition (6 conditions + total equilibrium score) + normativeValues.forEach((ageGroup, scores) { + for (int i = 0; i < scores.length; i++) { + double score = scores[i]; + + // Add the norm entry to the list + norms.add(Norm( + type: assessmentType, + sequenceNumber: i + 1, + age_range: ageGroup, + gender: Gender.MALE, + score_sum: score * 30, + trial_sum: 30, + )); + norms.add(Norm( + type: assessmentType, + sequenceNumber: i + 1, + age_range: ageGroup, + gender: Gender.FEMALE, + score_sum: score * 30, + trial_sum: 30, + )); + } + + // And save this to the database + // norms.forEach((norm) { + // Amplify.API.mutate( + // request: ModelMutations.create(norm), + // ); + // }); + }); + + return norms; +} diff --git a/lib/features/assessment/config/ctsib/pdf.dart b/lib/features/assessment/config/ctsib/pdf.dart index 3f5c8bee..8f59a110 100644 --- a/lib/features/assessment/config/ctsib/pdf.dart +++ b/lib/features/assessment/config/ctsib/pdf.dart @@ -1,9 +1,8 @@ -import 'package:amplify_flutter/amplify_flutter.dart'; import 'package:flutter/services.dart'; import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart'; -import 'package:visualpt/core/utils/amplify_utils.dart'; import 'package:visualpt/core/utils/math_utils.dart'; +import 'package:visualpt/core/views/styles.dart'; import 'package:visualpt/features/assessment/service/assessment_utils.dart'; import 'package:visualpt/models/ModelProvider.dart'; import 'package:visualpt/features/assessment/config/_pdf.dart'; @@ -15,6 +14,8 @@ class CtsibPDF extends PDF { Future generatePdf(Subject subject, Assessment assessment) async { await configPdfStyles(); + final norms = await ctsib.getNorms(subject.dob, subject.gender); + final pdf = Document( title: subject.last_name + subject.first_name + subject.dob.toString(), creator: "VisualPT Systems"); @@ -33,11 +34,11 @@ class CtsibPDF extends PDF { headerData(), subjectData(subject, assessment), Spacer(), - assessmentData(subject, assessment), + assessmentData(subject, assessment, norms), Spacer(), - conditionRatioTable(assessment), + sensoryMismatchData(assessment), Spacer(), - normativeData(subject, assessment), + normativeData(assessment, norms), Spacer(), interpretationData(assessment), //diagnosisData(), @@ -96,7 +97,7 @@ class CtsibPDF extends PDF { .inDays ~/ 365) .toString(), - style: detailBold), //TODO DOB to age + style: detailBold), ]), ), RichText( @@ -138,296 +139,355 @@ class CtsibPDF extends PDF { ]); } - Widget assessmentData(Subject subject, Assessment assessment) { - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - RichText( - textAlign: TextAlign.left, - text: TextSpan(text: "Assessment Information", style: subHeader)), - Table( - border: TableBorder.all(color: black), - columnWidths: { - 0: const IntrinsicColumnWidth(), - 1: const IntrinsicColumnWidth(), - 2: const IntrinsicColumnWidth(), - }, - children: [ - // Create table header - TableRow( + Widget assessmentData( + Subject subject, Assessment assessment, List? norms) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RichText( + textAlign: TextAlign.left, + text: TextSpan(text: "Assessment Score", style: subHeader)), + Container( + padding: const EdgeInsets.only(top: 10), + height: 220, // Increase height for better visibility + child: Stack( children: [ - Padding( - padding: const EdgeInsets.all(3.0), - child: Text('#', - style: TextStyle(fontWeight: FontWeight.bold), - textAlign: TextAlign.center), - ), - Padding( - padding: const EdgeInsets.all(3.0), - child: Text('Surface', - style: TextStyle(fontWeight: FontWeight.bold), - textAlign: TextAlign.center), - ), - Padding( - padding: const EdgeInsets.all(3.0), - child: Text('Vision', - style: TextStyle(fontWeight: FontWeight.bold), - textAlign: TextAlign.center), - ), - Padding( - padding: const EdgeInsets.all(3.0), - child: Text('Trials', - style: TextStyle(fontWeight: FontWeight.bold), - textAlign: TextAlign.center), - ), - Padding( - padding: const EdgeInsets.all(3.0), - child: Text('Avg Lat Sway (Max)', - style: TextStyle(fontWeight: FontWeight.bold), - textAlign: TextAlign.center)), - Padding( - padding: const EdgeInsets.all(3.0), - child: Text('Avg Lon Sway (Max)', - style: TextStyle(fontWeight: FontWeight.bold), - textAlign: TextAlign.center)), - Padding( - padding: const EdgeInsets.all(3.0), - child: Text('Leading Pose', - style: TextStyle(fontWeight: FontWeight.bold), - textAlign: TextAlign.center)), - Padding( - padding: const EdgeInsets.all(3.0), - child: Text('Equilibrium Score', - style: TextStyle(fontWeight: FontWeight.bold), - textAlign: TextAlign.center)), - Padding( - padding: const EdgeInsets.all(3.0), - child: Text('Result', - style: TextStyle(fontWeight: FontWeight.bold), - textAlign: TextAlign.center), - ), - ], - ), - for (int i = 0; i < (assessment.Conditions?.length ?? 1); i++) - TableRow( - children: [ - Padding( - padding: const EdgeInsets.all(3.0), - child: Text((i + 1).toString(), - style: detail, textAlign: TextAlign.center), - ), - Padding( - padding: const EdgeInsets.all(3.0), - child: Text(ctsib.surface[i], - style: detail, textAlign: TextAlign.center), - ), - Padding( - padding: const EdgeInsets.all(3.0), - child: Text(ctsib.vision[i], - style: detail, textAlign: TextAlign.center), - ), - Padding( - padding: const EdgeInsets.all(3.0), - child: Text( - assessment.Conditions?[i].trials?.length.toString() ?? - "0", - style: detail, - textAlign: TextAlign.center), - ), - Padding( - padding: const EdgeInsets.all(3.0), - child: Text( - "${CTSIB.averageSwayPerCondition(assessment)[i].$1.toStringAsFixed(2)} (${CTSIB.maxSwayPerCondition(assessment)[i].$1.toStringAsFixed(2)})°", - style: detail, - textAlign: TextAlign.center)), - Padding( - padding: const EdgeInsets.all(3.0), - child: Text( - "${CTSIB.averageSwayPerCondition(assessment)[i].$2.toStringAsFixed(2)} (${CTSIB.maxSwayPerCondition(assessment)[i].$2.toStringAsFixed(2)})°", - style: detail, - textAlign: TextAlign.center)), - Padding( - padding: const EdgeInsets.all(3.0), - child: Text( - CTSIB.getMostFrequentClassification(assessment, i), - style: detail, - textAlign: TextAlign.center)), - Padding( - padding: const EdgeInsets.all(3.0), - child: Text( - CTSIB - .getEquilibriumScores(assessment)[i] - .toStringAsFixed(2), - style: detail, - textAlign: TextAlign.center)), - Padding( - padding: const EdgeInsets.all(3.0), - child: Text( - "${AssessmentUtils.getAvgDuration(assessment.Conditions?[i].trials).toStringAsFixed(1)} / 30", - style: detail, - textAlign: TextAlign.center), + // Background chart for norm scores (gray background) + Chart( + grid: CartesianGrid( + xAxis: FixedAxis.fromStrings( + List.generate(((assessment.Conditions?.length ?? 0) + 1), + (i) => '\n\n'), + textStyle: const TextStyle(fontSize: 10), + marginStart: 30, + marginEnd: 30, + ), + yAxis: FixedAxis( + [0, 25, 50, 75, 100], + format: (v) => '$v', + marginStart: 1, + ), ), - ], - ), - // Create a table row for the total duration score - TableRow( - children: [ - Padding( - padding: const EdgeInsets.all(3.0), - child: Text('Total', - style: TextStyle(fontWeight: FontWeight.bold), - textAlign: TextAlign.center), - ), - Padding( - padding: const EdgeInsets.all(3.0), - child: Text('', textAlign: TextAlign.center), - ), - Padding( - padding: const EdgeInsets.all(3.0), - child: Text('', textAlign: TextAlign.center), + datasets: List.generate( + ((assessment.Conditions?.length ?? 0) + 1), (i) { + return BarDataSet( + legend: '', + color: PdfColors.grey, // Normative background + data: [ + PointChartValue( + (i).toDouble(), + (norms?[i].score_sum ?? 0) / + ((norms?[i].trial_sum ?? 1))) + ], + valuePosition: ValuePosition.auto, + width: 30, + ); + }), ), - Padding( - padding: const EdgeInsets.all(3.0), - child: Text( - assessment.Conditions?.fold(0, - (curr, c) => curr + (c.trials?.length ?? 0)) - .toString() ?? - '', - style: TextStyle(fontWeight: FontWeight.bold), - textAlign: TextAlign.center)), - Padding( - padding: const EdgeInsets.all(3.0), - child: Text( - "${(CTSIB.averageSwayPerCondition(assessment).fold<( - double, - double - )>(( - 0.0, - 0.0 - ), (avg, sway) => ( - avg.$1 + sway.$1, - avg.$2 + sway.$2 - )).$1 / ctsib.conditions).toStringAsFixed(2)}°", - style: TextStyle(fontWeight: FontWeight.bold), - textAlign: TextAlign.center)), - Padding( - padding: const EdgeInsets.all(3.0), - child: Text( - "${(CTSIB.averageSwayPerCondition(assessment).fold<( - double, - double - )>(( - 0.0, - 0.0 - ), (avg, sway) => ( - avg.$1 + sway.$1, - avg.$2 + sway.$2 - )).$2 / ctsib.conditions).toStringAsFixed(2)}°", - style: TextStyle(fontWeight: FontWeight.bold), - textAlign: TextAlign.center)), - Padding( - padding: const EdgeInsets.all(3.0), - child: Text( - CTSIB.getMostFrequentIndexAcrossAllEntries( - CTSIB.getPredictionTotals(assessment)), - style: TextStyle(fontWeight: FontWeight.bold), - textAlign: TextAlign.center)), - Padding( - padding: const EdgeInsets.all(3.0), - child: Text( - CTSIB - .getEquilibriumScores(assessment) - .fold( - 0.0, (prevScore, score) => prevScore + score) - .toStringAsFixed(1), - style: detail, - textAlign: TextAlign.center)), - Padding( - padding: const EdgeInsets.all(3.0), - child: Text( - "${AssessmentUtils.getTotalAverageDuration(assessment).toStringAsFixed(1)} / ${30 * ctsib.conditions} seconds", - style: TextStyle(fontWeight: FontWeight.bold), - textAlign: TextAlign.center), + // Foreground chart for patient scores + Chart( + grid: CartesianGrid( + xAxis: FixedAxis.fromStrings( + List.generate( + ((assessment.Conditions?.length ?? 0) + 1), + (i) => (assessment.Conditions?.length ?? 0) != i + ? '${ctsib.vision[i]}\n${ctsib.surface[i]}' + : 'Cumulative', + ), + textStyle: const TextStyle(fontSize: 10), + marginStart: 30, + marginEnd: 30, + ), + yAxis: FixedAxis( + [0, 25, 50, 75, 100], + format: (v) => '$v', + marginStart: 1, + divisions: true, + divisionsColor: PdfColor.fromInt(Styles.activeOrange.value), + ), + ), + datasets: List.generate( + ((assessment.Conditions?.length ?? 0) + 1), (i) { + if ((assessment.Conditions?.length ?? 0) != i) { + //TODO: Add multiple trials here + return BarDataSet( + legend: '${ctsib.vision[i]}\n${ctsib.surface[i]}', + color: ctsib.calculateScores(assessment)[i] > + (norms?[i].score_sum ?? 0) / + ((norms?[i].trial_sum ?? 1)) + ? PdfColors.green + : PdfColors.red, // Patient data color + width: 20, + data: [ + PointChartValue( + (i).toDouble(), + ctsib.calculateScores(assessment)[i], + ), + ], + buildValue: (context, point) => Text( + //If this is flagged as a fall, write FALL + point.y == 0 ? "FALL" : point.y.toStringAsFixed(2), + style: const TextStyle(fontSize: 10)), + valuePosition: ValuePosition.auto, + ); + } + return BarDataSet( + legend: '#${i + 1}', + color: (ctsib.calculateScores(assessment).fold(0.0, + (prevScore, score) => prevScore + score) / + i) > + (norms?[i].score_sum ?? 0) / + ((norms?[i].trial_sum ?? 1)) + ? PdfColors.green + : PdfColors.red, // Patient data color + width: 20, + data: [ + PointChartValue( + (i).toDouble(), + ctsib.calculateScores(assessment).fold( + 0.0, (prevScore, score) => prevScore + score) / + i, + ), + ], + buildValue: (context, point) => Text( + //If this is flagged as a fall, write FALL + point.y == 0 ? "FALL" : point.y.toStringAsFixed(2), + style: const TextStyle(fontSize: 10)), + valuePosition: ValuePosition.auto, + ); + }), ), ], ), - ], - ), - ]); + ), + ], + ); } - Widget conditionRatioTable(Assessment assessment) { - final c1 = AssessmentUtils.getAvgDuration(assessment.Conditions?[0].trials); - final c2 = AssessmentUtils.getAvgDuration(assessment.Conditions?[1].trials); - final c3 = AssessmentUtils.getAvgDuration(assessment.Conditions?[2].trials); - final c4 = AssessmentUtils.getAvgDuration(assessment.Conditions?[3].trials); - final c5 = AssessmentUtils.getAvgDuration(assessment.Conditions?[4].trials); - final c6 = AssessmentUtils.getAvgDuration(assessment.Conditions?[5].trials); - - // Calculating ratios safely - final somRatio = safeDivision(c2, c1); - final visRatio = safeDivision(c4, c1); - final vestRatio = safeDivision(c5, c1); - final prefRatio = safeDivision((c3 + c6), (c2 + c5)); - -// Now you can directly pass the string representations of ratios to determineStatus - final somStatus = determineStatus(somRatio); - final visStatus = determineStatus(visRatio); - final vestStatus = determineStatus(vestRatio); - final prefStatus = determineStatus(prefRatio); - - // Creating a table to display the results - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - RichText( - textAlign: TextAlign.left, - text: - TextSpan(text: "Condition Ratio Information", style: subHeader)), - Table( - border: TableBorder.all(color: black), + Widget sensoryMismatchData(Assessment assessment) { + // Function to calculate severity score based on the equilibrium score + List calculateSeverityScore(List equilibriumScore) { + List severityScores = List.filled(equilibriumScore.length, 0); + for (int i = 0; i < equilibriumScore.length; i++) { + if (equilibriumScore[i] >= 75) { + severityScores[i] = 3; // 75-100 range + } else if (equilibriumScore[i] >= 50) { + severityScores[i] = 2; // 50-74 range + } else if (equilibriumScore[i] >= 25) { + severityScores[i] = 1; // 25-49 range + } else { + severityScores[i] = 0; // 0-24 range + } + } + return severityScores; + } + + // Function to calculate the sensory ratio and format it as a string + String calculateSensoryRatio(List severityScores) { + String c2 = severityScores[1] == 3 ? 'S' : 'V'; // Condition 2 (SOM) + String c3 = severityScores[2] == 3 ? 'S' : 'V'; // Condition 3 (SOM) + String c4 = severityScores[3] == 3 ? 'V' : 'S'; // Condition 4 (VIS) + String c5 = severityScores[4] == 3 + ? 'Vest' + : severityScores[4] == 2 + ? 'S' + : 'V'; + String c6 = severityScores[5] == 3 + ? 'Vest' + : severityScores[5] == 2 + ? 'S' + : 'V'; + + String sensoryString = "$c2$c3$c4$c5$c6"; + return sensoryString; + } + + // Function to determine the sensory mismatch and check for Aphysio + // Function to determine the sensory mismatch based on sensoryString + String determineSensoryMismatch(String sensoryString, int compositeScore) { + // Direct mapping of sensoryString patterns to sensory mismatch categories + Map sensoryMappings = { + //TODO: have a PT review these, the color codings of these actually matter + "VVVVV": "VVM", + "VVVVS": "VVM", + "VVVSS": "VVM", + "VSVSV": "VVM", + "VVVSV": "VVM", + "SVVVV": "VSVM", + "SSSSS": "SVM", + "SVSVS": "SVM", + "SSSSV": "SVM", + "VSSSS": "SVM", + "SVSSS": "SVM", + "SSSVV": "SVVM", + "VVSSS": "SVVM", + "SSVSS": "Vh-SOM", + "SSVSV": "Vh-SOM", + "SSVVS": "Vh-SOM", + "SSVVV": "Vh-VIS", + "SSVVestV": "Vh-VIS", + "SSVVVest": "Vh-VIS", + "VVSSV": "Vh-VIS", + "SSSVS": "SVM", + }; + + // Check if the sensoryString matches any known mappings + if (sensoryMappings.containsKey(sensoryString)) { + return sensoryMappings[sensoryString]!; + } + +// Apply age-group thresholds based on the composite score + if (compositeScore >= 12) { + return sensoryMappings[sensoryString] ?? "Normal"; + } else { + return "SVVM or Complex SM"; // Complex SM for lower scores + } + } + + // Function to calculate the sensory ratio and format it as a color-coded string (Red/Blue) + TextSpan colorizeSensoryString(List severityScores) { + // Colors: Red for abnormal scores (visual/somatosensory), Blue for vestibular function + PdfColor redColor = + const PdfColor.fromInt(0xFF0000); // Red for VIS/SOM abnormalities + PdfColor blueColor = + const PdfColor.fromInt(0x0000FF); // Blue for VEST normal function + + // Map severity scores to sensory systems and assign appropriate colors + String c2 = severityScores[1] == 3 ? 'S' : 'V'; // Condition 2 (SOM) + String c3 = severityScores[2] == 3 ? 'S' : 'V'; // Condition 3 (SOM) + String c4 = severityScores[3] == 3 ? 'V' : 'S'; // Condition 4 (VIS) + String c5 = severityScores[4] == 3 + ? 'Vest' + : severityScores[4] == 2 + ? 'S' + : 'V'; // Condition 5 (VEST abnormal) + String c6 = severityScores[5] == 3 + ? 'Vest' + : severityScores[5] == 2 + ? 'S' + : 'V'; // Condition 6 (VEST abnormal) + + String sensoryString = "$c2$c3$c4$c5$c6"; + + // Count occurrences of 'S' and 'V' + int sCount = sensoryString.split('S').length - 1; // Count 'S' + int vCount = sensoryString.split('V').length - + 1 + + (c5 == "Vest" ? 1 : 0) + + (c6 == "Vest" ? 1 : 0); // Count 'V' + // Format the final sensory ratio + String sensoryRatio = sCount > vCount + ? "(${sCount}S:${vCount}V)" + : "(${vCount}V:${sCount}S)"; + + return TextSpan( children: [ - TableRow(children: [ - Padding( - padding: const EdgeInsets.all(3.0), - child: - Text('Test', style: TextStyle(fontWeight: FontWeight.bold)), - ), - Padding( - padding: const EdgeInsets.all(3.0), - child: Text('Ratio', - style: TextStyle(fontWeight: FontWeight.bold), - textAlign: TextAlign.center), - ), - Padding( - padding: const EdgeInsets.all(3.0), - child: Text('Status', - style: TextStyle(fontWeight: FontWeight.bold), - textAlign: TextAlign.center), - ), - ]), - _createConditionRow("Somatosensory (SOM) Test", somRatio, somStatus), - _createConditionRow( - "Visual Dependence (VIS) Test", visRatio, visStatus), - _createConditionRow( - "Vestibular Function (VEST) Test", vestRatio, vestStatus), - _createConditionRow( - "Preferred Sensory System (PREF) Test", prefRatio, prefStatus), + TextSpan( + text: c2, + style: TextStyle( + color: c2 == 'S' ? blueColor : redColor, + font: boldFont, + fontSize: 12.0, + lineSpacing: 2.0), + ), + TextSpan( + text: c3, + style: TextStyle( + color: c3 == 'S' ? blueColor : redColor, + font: boldFont, + fontSize: 12.0, + lineSpacing: 2.0), + ), + TextSpan( + text: c4, + style: TextStyle( + color: c4 == 'V' ? blueColor : redColor, + font: boldFont, + fontSize: 12.0, + lineSpacing: 2.0), + ), + TextSpan( + text: c5, + style: TextStyle( + color: c5 == 'Vest' ? blueColor : redColor, + font: boldFont, + fontSize: 12.0, + lineSpacing: 2.0), + ), + TextSpan( + text: c6, + style: TextStyle( + color: c6 == 'Vest' ? blueColor : redColor, + font: boldFont, + fontSize: 12.0, + lineSpacing: 2.0), + ), + const TextSpan(text: " "), + TextSpan( + text: sensoryRatio, + style: detailBold, + ) ], - ) + ); + } + + final scores = ctsib.calculateScores(assessment); + final severityScores = calculateSeverityScore(scores); + final compositeScore = + severityScores.fold(0, (prev, cur) => prev + cur); + final sensoryRatio = calculateSensoryRatio(severityScores); + final sensoryMismatch = + determineSensoryMismatch(sensoryRatio, compositeScore); + + // Display the sensory ratio and mismatch in a table format + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + RichText( + textAlign: TextAlign.left, + text: TextSpan(text: "Sensory Ratios & Mismatch", style: subHeader), + ), + Row(children: [ + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + RichText( + text: TextSpan(children: [ + TextSpan(text: "Severity Scores: ", style: detail), + TextSpan(text: severityScores.toString(), style: detailBold), + ]), + ), + RichText( + text: TextSpan(children: [ + TextSpan(text: "Composite Score: ", style: detail), + TextSpan(text: compositeScore.toString(), style: detailBold), + ]), + ), + ]), + Spacer(), + Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ + RichText( + text: TextSpan(children: [ + TextSpan(text: 'Sensory Ratio: ', style: detail), + colorizeSensoryString(severityScores), + ]), + ), + RichText( + text: TextSpan(children: [ + TextSpan(text: "Sensory Mismatch: ", style: detail), + TextSpan(text: sensoryMismatch, style: detailBold), + ]), + ), + ]) + ]) ]); } //TODO: Add normative data, and potientally another data field for collecting the patients fall history - Widget normativeData(Subject subject, Assessment assessment) { - final totalDuration = assessment.Conditions?.map((s) => s.trials?.fold(0.0, - (previousValue, element) => previousValue + element.duration)) - .reduce((a, b) => (a ?? 0.0) + (b ?? 0.0)) ?? - 0.0; + Widget normativeData(Assessment assessment, List? norms) { + final message = ctsib.getNormativeMessage(assessment, norms); + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ RichText( textAlign: TextAlign.left, text: TextSpan(text: "Normative Comparison", style: subHeader)), - RichText( - text: TextSpan( - text: getNormativeMessage(totalDuration, subject.dob), - style: detail)), + RichText(text: TextSpan(text: message, style: detail)), ]); } @@ -437,17 +497,18 @@ class CtsibPDF extends PDF { RichText( textAlign: TextAlign.left, text: TextSpan(text: "Interpretation", style: subHeader)), - if (is9xFallRisk) - RichText( - textAlign: TextAlign.left, - text: TextSpan( - text: - "Patient displays deficits on Condition 5 and Condition 6 of the CTSIB which puts them at high risk (9x more likely) for a fall over the next 30 days.", - style: detail), - ), + // if (is9xFallRisk) + // RichText( + // textAlign: TextAlign.left, + // text: TextSpan( + // text: + // "Patient displays deficits on Condition 5 and Condition 6 of the CTSIB which puts them at high risk (9x more likely) for a fall over the next 30 days.", + // style: detail), + // ), RichText( text: TextSpan( - text: generateConditionRatioText(assessment), style: detail)), + text: "Generative AI snippets coming soon", style: detail)), + //generateConditionRatioText(assessment), style: detail)), // "1) Vision Dependence: Patients become unstable in conditions 2, 3, 5 & 6 with eyes closed.", // "2) Surface/somatosensory dependence: Patient becomes unstable in conditions 4, 5 & 6 because they stand on a soft surface (foam).", // "3) Vestibular loss: Patient becomes unstable in conditions 5 & 6 because they can't rely on vision or surface / somatosensory function.", @@ -455,14 +516,6 @@ class CtsibPDF extends PDF { ]); } - // Widget diagnosisData() { - // return Column(children: [ - // RichText( - // text: TextSpan(text: "Diagnostic Information", style: subHeader)), - // RichText(text: TextSpan(text: "Coming Soon", style: detailBold)), - // ]); - // } - Widget footerData() { return Footer( padding: EdgeInsets.zero, @@ -481,113 +534,63 @@ class CtsibPDF extends PDF { style: disclaimer, text: "No Distribution without Permission")), ]), - // trailing: Column( - // mainAxisAlignment: MainAxisAlignment.end, - // crossAxisAlignment: CrossAxisAlignment.end, - // children: [ - // // RichText( - // // text: TextSpan( - // // style: disclaimer, - // // text: "Minimal Detectable Change: 0.1m/sec")), - // // RichText( - // // text: TextSpan( - // // style: disclaimer, - // // text: "Minimum Clinically Important Difference: 0.1m/sec")), - // // RichText( - // // text: TextSpan( - // // style: disclaimer, - // // text: "Test/Re-test Reliability: ICC > 0.7")) - // ], - // ), ); } - TableRow _createConditionRow(String testName, double ratio, String status) { - return TableRow(children: [ - Padding( - padding: const EdgeInsets.all(3.0), - child: Text(testName, style: detail), - ), - Padding( - padding: const EdgeInsets.all(3.0), - child: Text("${ratio.toStringAsFixed(2)}%", - style: detail, textAlign: TextAlign.center), - ), - Padding( - padding: const EdgeInsets.all(3.0), - child: Text(status, style: detail, textAlign: TextAlign.center), - ), - ]); + String determineStatus(double ratio) { + if (ratio == 0.0) { + return "Inconclusive"; // Ratio is 0.0, indicating an invalid or uncalculable result + } else if (ratio < 99) { + return "Dysfunctional"; // Patient is dysfunctional if the ratio is less than 1 + } else { + return "Functional"; // Patient is considered functional otherwise + } } -} -String getNormativeMessage(double totalDuration, TemporalDate dob) { - int age = getAge(dob); - double norm = CTSIB.getAgeNorm(age); - int percent = - (((totalDuration - norm) / ((totalDuration + norm) / 2)) * 100).round(); + //TODO: This likely needs to be based on the equlibrium score + bool checkFallRisk(Assessment assessment) { + // Calculate the total duration of the assessment (only for conditions 5 and 6) + bool is9xFallRisk(int i) { + final total = + AssessmentUtils.getAvgDuration(assessment.Conditions?[i].trials) > + 30.0; + return total; + } - if (percent.abs() > 10) { - return "Patients total time is ${totalDuration.toStringAsFixed(2)} / 180 seconds, which is ${percent.abs()}% of age norms, with a mean of $norm seconds"; - } else { - return "Patient is within 10% of normative data, which has a mean of $norm seconds"; + // Determine if the patient is at risk of falling based on the total duration + return is9xFallRisk(4) || is9xFallRisk(5); } -} -String determineStatus(double ratio) { - if (ratio == 0.0) { - return "Inconclusive"; // Ratio is 0.0, indicating an invalid or uncalculable result - } else if (ratio < 99) { - return "Dysfunctional"; // System is dysfunctional if the ratio is less than 1 - } else { - return "Functional"; // System is considered functional otherwise - } -} + // String generateConditionRatioText(Assessment assessment) { + // final scores = CTSIB().calculateScores(assessment); -bool checkFallRisk(Assessment assessment) { - // Calculate the total duration of the assessment (only for conditions 5 and 6) - bool is9xFallRisk(int i) { - final total = - AssessmentUtils.getAvgDuration(assessment.Conditions?[i].trials) > 30.0; - return total; - } + // // Calculating ratios safely + // final somRatio = safeDivision(scores[1], scores[0]); + // final visRatio = safeDivision(scores[3], scores[0]); + // final vestRatio = safeDivision(scores[4], scores[0]); - // Determine if the patient is at risk of falling based on the total duration - return is9xFallRisk(4) || is9xFallRisk(5); -} + // final ratios = [ + // ( + // vestRatio, + // "Patient demonstrates a ${vestRatio.toStringAsFixed(2)}% ratio on the vestibular function test which puts them in the dysfunctional category." + // ), + // ( + // visRatio, + // "Patient demonstrates a ${visRatio.toStringAsFixed(2)}% ratio on the visual dependence test which puts them in the dysfunctional category." + // ), + // ( + // somRatio, + // "Patient demonstrates a ${somRatio.toStringAsFixed(2)}% ratio on the somatic sensory function test which puts them in the dysfunctional category." + // ), + // ]; -String generateConditionRatioText(Assessment assessment) { - final c1 = AssessmentUtils.getAvgDuration(assessment.Conditions?[0].trials); - final c2 = AssessmentUtils.getAvgDuration(assessment.Conditions?[1].trials); - final c4 = AssessmentUtils.getAvgDuration(assessment.Conditions?[3].trials); - final c5 = AssessmentUtils.getAvgDuration(assessment.Conditions?[4].trials); - - // Calculating ratios safely - final somRatio = safeDivision(c2, c1); - final visRatio = safeDivision(c4, c1); - final vestRatio = safeDivision(c5, c1); - - final ratios = [ - ( - vestRatio, - "Patient demonstrates a ${vestRatio.toStringAsFixed(2)}% ratio on the vestibular function test which puts them in the dysfunctional category." - ), - ( - visRatio, - "Patient demonstrates a ${visRatio.toStringAsFixed(2)}% ratio on the visual dependence test which puts them in the dysfunctional category." - ), - ( - somRatio, - "Patient demonstrates a ${somRatio.toStringAsFixed(2)}% ratio on the somatic sensory function test which puts them in the dysfunctional category." - ), - ]; - - // Determine the worst ratio among the three - final lowest = ratios.reduce((a, b) => a.$1 < b.$1 ? a : b); - - if (lowest.$1 > 99) { - return "No significant findings were observed."; - } + // // Determine the worst ratio among the three + // final lowest = ratios.reduce((a, b) => a.$1 < b.$1 ? a : b); - return lowest.$2; + // if (lowest.$1 > 99) { + // return "No significant findings were observed."; + // } + + // return lowest.$2; + // } } diff --git a/lib/features/assessment/config/ctsib_collect/ctsib_collect.dart b/lib/features/assessment/config/ctsib_collect/ctsib_collect.dart deleted file mode 100644 index 84dea1ec..00000000 --- a/lib/features/assessment/config/ctsib_collect/ctsib_collect.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:visualpt/features/assessment/view/_assessments.dart'; -import 'package:visualpt/features/inference/constants/model_file.dart'; - -import 'package:visualpt/models/ModelProvider.dart'; - -class CTSIB_COLLECT implements BaseAssessment { - @override - AssessmentType get type => AssessmentType.CTSIB_COLLECT; - - @override - String get name => - "Clinical Test of Sensory Integration & Balance for AI Training"; - - @override - String get normalDuration => "10 minutes"; - - @override - get details => - """Clinical Test of Sensory Integration & Balance is a test used to assess the sensory integration of balance. \n\nThe test is used to identify the sensory system that is contributing to balance deficits.\n\nThis assessment specifically uses the 4th condition, which presents the most difficult challenge to a healthy individual. The subject stands on a foam surface with eyes closed."""; - - @override - String get imagePath => "assets/images/ctsib_mainlogo.png"; - - @override - int get conditions => 1; - - @override - int get trials => 3; - - @override - List get conditionNames => [ - "Eyes Closed, Foam Surface", - ]; - - @override - List> get conditionInstructions => List.filled(conditions, [ - "Patients' landmark boundaries must be as close to the fixed bounding box as possible", - "Camera position must be static and level", - "Lighting must be sufficient to clearly see the subject", - ]); - - @override - List> get conditionInference => [ - [InferenceType.poseDetection, InferenceType.poseClassification] - ]; - - @override - List get conditionObtrusion => [false]; - - @override - List get conditionInstability => [false]; - - @override - Map, String> get formFields => {}; - - @override - bool get isTrainingData => true; - - @override - bool get isAvailable => true; -} diff --git a/lib/features/assessment/config/ctsib_collect/pdf.dart b/lib/features/assessment/config/ctsib_collect/pdf.dart deleted file mode 100644 index 2135ed2b..00000000 --- a/lib/features/assessment/config/ctsib_collect/pdf.dart +++ /dev/null @@ -1,141 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:pdf/pdf.dart'; -import 'package:pdf/widgets.dart'; -import 'package:visualpt/features/assessment/config/ctsib_collect/ctsib_collect.dart'; -import 'package:visualpt/models/ModelProvider.dart'; -import 'package:visualpt/features/assessment/config/_pdf.dart'; - -class CtsibCollectPDF extends PDF { - final ctsib = CTSIB_COLLECT(); - @override - Future generatePdf(Subject subject, Assessment assessment) async { - await configPdfStyles(); - - final pdf = Document( - title: subject.last_name + subject.first_name + subject.dob.toString(), - creator: "VisualPT Systems"); - - pdf.addPage( - Page( - pageTheme: PageTheme( - pageFormat: PdfPageFormat.letter, - margin: EdgeInsets.all(halfinch), - ), - build: (Context context) => SizedBox( - height: 11 * inch, - width: 8.5 * inch, - child: - Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - headerData(), - subjectData(subject, assessment), - Spacer(), - thankYou(), - Spacer(), - ]), - ), - ), - ); - return pdf.save(); - } - - Widget headerData() { - return Header( - child: Column( - children: [ - RichTextLogo, - RichText( - textAlign: TextAlign.center, - text: TextSpan(text: "${ctsib.name} Report", style: header), - overflow: TextOverflow.span), - ], - ), - ); - } - - Widget subjectData(Subject subject, Assessment assessment) { - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - RichText( - textAlign: TextAlign.left, - text: TextSpan(text: "Patient Information", style: subHeader)), - Row(children: [ - Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - RichText( - text: TextSpan(children: [ - TextSpan(text: "Name: ", style: detail), - TextSpan( - text: "${subject.first_name} ${subject.last_name}", - style: detailBold), - ]), - ), - RichText( - text: TextSpan(children: [ - TextSpan(text: "DOB: ", style: detail), - TextSpan( - text: formatDate(subject.dob.getDateTime()), - style: detailBold), - ]), - ), - RichText( - text: TextSpan(children: [ - TextSpan(text: "Age: ", style: detail), - TextSpan( - text: (DateTime.now() - .difference(subject.dob.getDateTime()) - .inDays ~/ - 365) - .toString(), - style: detailBold), //TODO DOB to age - ]), - ), - RichText( - text: TextSpan(children: [ - TextSpan(text: "Gender: ", style: detail), - TextSpan(text: subject.gender.name, style: detailBold), - ]), - ), - ]), - Spacer(), - Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ - //TODO internationalize this date format to local time - RichText( - text: TextSpan(children: [ - TextSpan(text: "Date: ", style: detail), - TextSpan( - text: - formatDate(DateTime.parse(assessment.datetime.toString())), - style: detailBold), - ])), - RichText( - text: TextSpan(children: [ - TextSpan(text: "Time: ", style: detail), - TextSpan( - text: formatTime( - DateTime.parse(assessment.datetime.toString()).toLocal()), - style: detailBold), - ]), - ), - RichText( - text: TextSpan(children: [ - TextSpan(text: "Total Duration: ", style: detail), - TextSpan( - text: formatDuration(assessment.duration), style: detailBold), - ]), - ), - ]), - ]), - ]); - } - - Widget thankYou() { - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - RichText( - textAlign: TextAlign.left, - text: TextSpan(text: "Thank You", style: subHeader)), - RichText( - text: TextSpan( - text: - "Thank you for collecting CTSIB data for VisualPT.AI, this data will make the pose classification model better for screening patients with real risks. If you have any questions or concerns, please contact VisualPT.AI support.", - style: detail)), - ]); - } -} diff --git a/lib/features/assessment/config/dev/dev.dart b/lib/features/assessment/config/dev/dev.dart new file mode 100644 index 00000000..d807ad00 --- /dev/null +++ b/lib/features/assessment/config/dev/dev.dart @@ -0,0 +1,94 @@ +import 'package:amplify_flutter/amplify_flutter.dart'; +import 'package:visualpt/models/ModelProvider.dart'; +import 'package:visualpt/features/assessment/view/_assessments.dart'; +import 'package:visualpt/features/inference/constants/model_file.dart'; + +class DEV implements BaseAssessment { + @override + AssessmentType get type => AssessmentType.DEV; + + @override + String get name => "Development"; + + @override + String get normalDuration => "X minutes"; + + @override + get details => + """For testing with fine control over the assessment parameters"""; + + @override + String get imagePath => "assets/images/vpt_appicon.png"; + + @override + int get conditions => 1; + + @override + int get trials => 1; + + @override + int get trialDuration => 5; + + @override + List get conditionNames => [ + "Development", + ]; + + List get surface => [ + "Digital", + ]; + + List get vision => [ + "Computer", + ]; + + @override + List> get conditionInstructions => List.filled(conditions, [ + "Make this app work!", + ]); + + @override + List> get conditionInference => List.filled(conditions, + [InferenceType.poseDetection, InferenceType.poseClassification]); + + @override + List get conditionObtrusion => [false]; + + @override + List get conditionInstability => [false]; + + @override + Map, String> get formFields => {}; + + @override + bool get isTrainingData => true; + + @override + bool get isAvailable => true; + + @override + Future?> getNorms(TemporalDate dob, Gender gender) async { + return null; + } + + @override + Future setNorm(TemporalDate dob, Gender gender, int seqNum, + double score, int trials) async { + return null; + } + + @override + List calculateScores(Assessment assessment) { + return []; + } + + @override + String getNormativeMessage(Assessment assessment, List? norms) { + return ""; + } + + @override + bool isComplete(args) { + return false; + } +} diff --git a/lib/features/assessment/config/dev/pdf.dart b/lib/features/assessment/config/dev/pdf.dart new file mode 100644 index 00000000..0561fe1a --- /dev/null +++ b/lib/features/assessment/config/dev/pdf.dart @@ -0,0 +1,34 @@ +import 'package:flutter/services.dart'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart'; +import 'package:visualpt/features/assessment/config/_pdf.dart'; +import 'package:visualpt/models/ModelProvider.dart'; + +const halfinch = 36.0; +const inch = 72.0; +const height = 11 * inch; +const width = 8.5 * inch; + +class DevPDF extends PDF { + @override + Future generatePdf(Subject subject, Assessment assessment) async { + try { + final pdf = Document(title: "Test", creator: "VisualPT Systems"); + + pdf.addPage( + Page( + pageTheme: const PageTheme( + pageFormat: PdfPageFormat.letter, + margin: EdgeInsets.all(halfinch), + ), + build: (Context context) => + SizedBox(height: height, width: width, child: Text("Test")), + ), + ); + final data = pdf.save(); + return data; + } on Exception catch (error) { + throw Exception('Error generating PDF: $error'); + } + } +} diff --git a/lib/features/assessment/config/fist/fist.dart b/lib/features/assessment/config/fist/fist.dart index 4c01c471..b363e39b 100644 --- a/lib/features/assessment/config/fist/fist.dart +++ b/lib/features/assessment/config/fist/fist.dart @@ -1,6 +1,10 @@ +import 'package:amplify_core/src/types/temporal/temporal_date.dart'; import 'package:visualpt/features/assessment/view/_assessments.dart'; import 'package:visualpt/features/inference/constants/model_file.dart'; +import 'package:visualpt/models/Assessment.dart'; import 'package:visualpt/models/AssessmentType.dart'; +import 'package:visualpt/models/Gender.dart'; +import 'package:visualpt/models/Norm.dart'; class FIST implements BaseAssessment { @override @@ -25,6 +29,9 @@ class FIST implements BaseAssessment { @override int get trials => 1; + @override + int get trialDuration => 30; + @override List get conditionNames => ["Sitting"]; @@ -53,4 +60,34 @@ class FIST implements BaseAssessment { @override bool get isAvailable => false; + + @override + Future?> getNorms(TemporalDate dob, Gender gender) { + // TODO: implement getNorm + throw UnimplementedError(); + } + + @override + Future setNorm( + TemporalDate dob, Gender gender, int seqNum, double score, int trials) { + // TODO: implement setNorm + throw UnimplementedError(); + } + + @override + List calculateScores(Assessment assessment) { + // TODO: implement calculateScores + throw UnimplementedError(); + } + + @override + String getNormativeMessage(Assessment assessment, List? norms) { + // TODO: implement getNormativeMessage + throw UnimplementedError(); + } + + @override + bool isComplete(args) { + return false; + } } diff --git a/lib/features/assessment/config/gait/gait.dart b/lib/features/assessment/config/gait/gait.dart index 146c9a4a..6f156e50 100644 --- a/lib/features/assessment/config/gait/gait.dart +++ b/lib/features/assessment/config/gait/gait.dart @@ -27,6 +27,9 @@ class GAIT implements BaseAssessment { @override int get trials => 1; + @override + int get trialDuration => 30; + @override List get conditionNames => ["Walk"]; @@ -55,6 +58,36 @@ class GAIT implements BaseAssessment { @override bool get isAvailable => false; + + @override + Future?> getNorms(TemporalDate dob, Gender gender) { + // TODO: implement getNorm + throw UnimplementedError(); + } + + @override + Future setNorm( + TemporalDate dob, Gender gender, int seqNum, double score, int trials) { + // TODO: implement setNorm + throw UnimplementedError(); + } + + @override + List calculateScores(Assessment assessment) { + // TODO: implement calculateScores + throw UnimplementedError(); + } + + @override + String getNormativeMessage(Assessment assessment, List? norms) { + // TODO: implement getNormativeMessage + throw UnimplementedError(); + } + + @override + bool isComplete(args) { + return false; + } } class GaitNorms { diff --git a/lib/features/assessment/config/mctsib/mctsib.dart b/lib/features/assessment/config/mctsib/mctsib.dart index f14c3b9b..d7351402 100644 --- a/lib/features/assessment/config/mctsib/mctsib.dart +++ b/lib/features/assessment/config/mctsib/mctsib.dart @@ -1,6 +1,10 @@ +import 'package:amplify_api/amplify_api.dart'; +import 'package:amplify_flutter/amplify_flutter.dart'; +import 'package:visualpt/features/assessment/config/ctsib/ctsib.dart'; import 'package:visualpt/features/assessment/view/_assessments.dart'; import 'package:visualpt/features/inference/constants/model_file.dart'; import 'package:visualpt/features/inference/service/pose_classification/pose_class_output.dart'; +import 'package:visualpt/features/inference/service/pose_detection/pose_detection_output.dart'; import 'package:visualpt/models/ModelProvider.dart'; class MCTSIB implements BaseAssessment { @@ -26,6 +30,9 @@ class MCTSIB implements BaseAssessment { @override int get trials => 1; + @override + int get trialDuration => 30; + @override List get conditionNames => [ "Eyes Open, Firm Surface", @@ -150,4 +157,142 @@ class MCTSIB implements BaseAssessment { return classifications[mostFrequentIndex]?.toString() ?? "invalid"; } + + @override + Future?> getNorms(TemporalDate dob, Gender gender) async { + try { + final AgeRange ageRange = getAgeEnumByTemporalDate(dob); + final request = ModelQueries.list(Norm.classType, + where: Norm.TYPE + .eq(AssessmentType.CTSIB) + .and(Norm.AGE_RANGE.eq(ageRange).and(Norm.GENDER.eq(gender)))); + final response = await Amplify.API.query(request: request).response; + + final norms = response.data?.items; + if (norms == null || norms.isEmpty) { + return null; + } else { + return norms.map((e) => e as Norm).toList(); + } + } catch (e) { + print(e); + return null; + } + } + + @override + Future setNorm(TemporalDate dob, Gender gender, int seqNum, + double score, int trials) async { + try { + final existingNorms = await getNorms(dob, gender); + final existingNorm = existingNorms + ?.firstWhere((element) => element.sequenceNumber == seqNum); + + if (existingNorm != null) { + final request = ModelMutations.update(existingNorm.copyWith( + score_sum: existingNorm.score_sum + score, + trial_sum: existingNorm.trial_sum + trials, + )); + final response = await Amplify.API.mutate(request: request).response; + return response.data; + } else { + final newNorm = Norm( + type: AssessmentType.CTSIB, + age_range: getAgeEnumByTemporalDate(dob), + gender: gender, + sequenceNumber: seqNum, + score_sum: score, + trial_sum: trials, + ); + final request = ModelMutations.create(newNorm); + final response = await Amplify.API.mutate(request: request).response; + return response.data; + } + } catch (e) { + print(e); + return null; + } + } + + @override + List calculateScores(Assessment assessment) { + //Equilibrium Score is the the proportion of the actual sway to the swayLimit, multiplied by 100, summed up and divided by the number of detections per trial, then summed across all trials and divided by the number of trials + final validConditions = assessment.Conditions?.where((condition) => + condition.trials != null && condition.trials!.isNotEmpty) + .toList() ?? + []; + + try { + final conditionScores = validConditions.map((condition) { + final trialsCountAndScoreSum = condition.trials!.fold<(int, double)>( + (0, 0.0), (trialsCountAndScoreIterator, trial) { + if (trial.Detections == null || trial.Detections!.isEmpty) { + return trialsCountAndScoreIterator; + } + final detectionCountAndScoreSum = trial.Detections! + .fold<(int, double)>((0, 0.0), + (detectionCountAndScoreIterator, detection) { + final poseOutput = + PoseDetectionOutput.fromString(detection.keypoints); + final (xSway, zSway) = PoseDetectionOutput.computeAngles( + poseOutput.map((e) => (e[0], e[1], e[2])).toList()); + final latProportion = xSway / swayLimits[2]; + //If the subject is leaning forward, the angle will be negative, so use the anterior sway limit + // final lonProportion = + // zSway > 0 ? zSway / swayLimits[0] : zSway / swayLimits[1]; + //TODO: since the zSway is not to scale, the lonProportion will usually be greater than 1, usually leading to an equilibrium score of 0. + // A new pose detection model should be trained to fix this + if (latProportion > 1 /* || lonProportion > 1*/) { + return ( + detectionCountAndScoreIterator.$1 + 1, + detectionCountAndScoreIterator.$2, + ); + } + final proportionOfFailure = latProportion; + //sqrt(pow(latProportion, 2) + pow(lonProportion, 2)); + final detectionScore = 100 * (1 - proportionOfFailure); + return ( + detectionCountAndScoreIterator.$1 + 1, + detectionCountAndScoreIterator.$2 + detectionScore + ); + }); + final trialScore = + detectionCountAndScoreSum.$2 / detectionCountAndScoreSum.$1; + + return ( + trialsCountAndScoreIterator.$1 + 1, + trialsCountAndScoreIterator.$2 + trialScore + ); + }); + final value = trialsCountAndScoreSum.$2 / trialsCountAndScoreSum.$1; + return value.isNaN ? 0.0 : value; + }).toList(); + return conditionScores; + } on Exception catch (e) { + print(e); + return List.generate(validConditions.length, (index) => 0.0, + growable: false); + } + } + + @override + String getNormativeMessage(Assessment assessment, List? norms) { + final globalScores = norms + ?.map((s) => s.trial_sum == 0 ? 0 : s.score_sum / s.trial_sum) + .reduce((a, b) => (a) + (b)) ?? + 0.0; + final localScores = assessment.Conditions?.map((s) => s.trials?.fold(0.0, + (previousValue, element) => previousValue + element.duration)) + .reduce((a, b) => (a ?? 0.0) + (b ?? 0.0)) ?? + 0.0; + final percent = ((localScores - globalScores) / globalScores) * 100; + + return "Patients total score is $localScores. The global norm is $globalScores, which is ${percent.abs()}% of age gender norms"; + } + + @override + bool isComplete(args) { + final (isFall, __) = CTSIB.poseToCtsibData(args.toString()); + return isFall; + } } diff --git a/lib/features/assessment/config/mctsib/pdf.dart b/lib/features/assessment/config/mctsib/pdf.dart index a4cca93d..1804953b 100644 --- a/lib/features/assessment/config/mctsib/pdf.dart +++ b/lib/features/assessment/config/mctsib/pdf.dart @@ -1,8 +1,6 @@ -import 'package:amplify_flutter/amplify_flutter.dart'; import 'package:flutter/services.dart'; import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart'; -import 'package:visualpt/core/utils/amplify_utils.dart'; import 'package:visualpt/core/utils/math_utils.dart'; import 'package:visualpt/features/assessment/config/mctsib/mctsib.dart'; import 'package:visualpt/features/assessment/service/assessment_utils.dart'; @@ -11,11 +9,13 @@ import 'package:visualpt/features/assessment/config/_pdf.dart'; import 'package:visualpt/features/assessment/config/ctsib/ctsib.dart'; class MctsibPDF extends PDF { - final ctsib = MCTSIB(); + final mctsib = MCTSIB(); @override Future generatePdf(Subject subject, Assessment assessment) async { await configPdfStyles(); + final norms = await mctsib.getNorms(subject.dob, subject.gender); + final pdf = Document( title: subject.last_name + subject.first_name + subject.dob.toString(), creator: "VisualPT Systems"); @@ -38,7 +38,7 @@ class MctsibPDF extends PDF { Spacer(), conditionRatioTable(assessment), Spacer(), - normativeData(subject, assessment), + normativeData(assessment, norms), Spacer(), interpretationData(assessment), //diagnosisData(), @@ -58,7 +58,7 @@ class MctsibPDF extends PDF { RichTextLogo, RichText( textAlign: TextAlign.center, - text: TextSpan(text: "${ctsib.name} Report", style: header), + text: TextSpan(text: "${mctsib.name} Report", style: header), overflow: TextOverflow.span), ], ), @@ -216,12 +216,12 @@ class MctsibPDF extends PDF { ), Padding( padding: const EdgeInsets.all(3.0), - child: Text(ctsib.surface[i], + child: Text(mctsib.surface[i], style: detail, textAlign: TextAlign.center), ), Padding( padding: const EdgeInsets.all(3.0), - child: Text(ctsib.vision[i], + child: Text(mctsib.vision[i], style: detail, textAlign: TextAlign.center), ), Padding( @@ -253,8 +253,8 @@ class MctsibPDF extends PDF { Padding( padding: const EdgeInsets.all(3.0), child: Text( - CTSIB - .getEquilibriumScores(assessment)[i] + mctsib + .calculateScores(assessment)[i] .toStringAsFixed(2), style: detail, textAlign: TextAlign.center)), @@ -305,7 +305,7 @@ class MctsibPDF extends PDF { ), (avg, sway) => ( avg.$1 + sway.$1, avg.$2 + sway.$2 - )).$1 / ctsib.conditions).toStringAsFixed(2)}°", + )).$1 / mctsib.conditions).toStringAsFixed(2)}°", style: TextStyle(fontWeight: FontWeight.bold), textAlign: TextAlign.center)), Padding( @@ -320,7 +320,7 @@ class MctsibPDF extends PDF { ), (avg, sway) => ( avg.$1 + sway.$1, avg.$2 + sway.$2 - )).$2 / ctsib.conditions).toStringAsFixed(2)}°", + )).$2 / mctsib.conditions).toStringAsFixed(2)}°", style: TextStyle(fontWeight: FontWeight.bold), textAlign: TextAlign.center)), Padding( @@ -333,8 +333,8 @@ class MctsibPDF extends PDF { Padding( padding: const EdgeInsets.all(3.0), child: Text( - CTSIB - .getEquilibriumScores(assessment) + mctsib + .calculateScores(assessment) .fold( 0.0, (prevScore, score) => prevScore + score) .toStringAsFixed(1), @@ -343,7 +343,7 @@ class MctsibPDF extends PDF { Padding( padding: const EdgeInsets.all(3.0), child: Text( - "${AssessmentUtils.getTotalAverageDuration(assessment).toStringAsFixed(1)} / ${30 * ctsib.conditions} seconds", + "${AssessmentUtils.getTotalAverageDuration(assessment).toStringAsFixed(1)} / ${30 * mctsib.conditions} seconds", style: TextStyle(fontWeight: FontWeight.bold), textAlign: TextAlign.center), ), @@ -403,19 +403,14 @@ class MctsibPDF extends PDF { ]); } - //TODO: Add normative data, and potientally another data field for collecting the patients fall history - Widget normativeData(Subject subject, Assessment assessment) { - final totalDuration = assessment.Conditions?.map((s) => s.trials?.fold(0.0, - (previousValue, element) => previousValue + element.duration)) - .reduce((a, b) => (a ?? 0.0) + (b ?? 0.0)) ?? - 0.0; + Widget normativeData(Assessment assessment, List? norms) { return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ RichText( textAlign: TextAlign.left, text: TextSpan(text: "Normative Comparison", style: subHeader)), RichText( text: TextSpan( - text: getNormativeMessage(totalDuration, subject.dob), + text: mctsib.getNormativeMessage(assessment, norms), style: detail)), ]); } @@ -489,19 +484,6 @@ class MctsibPDF extends PDF { } } -String getNormativeMessage(double totalDuration, TemporalDate dob) { - int age = getAge(dob); - double norm = CTSIB.getAgeNorm(age); - int percent = - (((totalDuration - norm) / ((totalDuration + norm) / 2)) * 100).round(); - - if (percent.abs() > 10) { - return "Patients total time is ${totalDuration.toStringAsFixed(2)} / 180 seconds, which is ${percent.abs()}% of age norms, with a mean of $norm seconds"; - } else { - return "Patient is within 10% of normative data, which has a mean of $norm seconds"; - } -} - String determineStatus(double ratio) { if (ratio == 0.0) { return "Inconclusive"; // Ratio is 0.0, indicating an invalid or uncalculable result diff --git a/lib/features/assessment/config/reach/reach.dart b/lib/features/assessment/config/reach/reach.dart index 3312b96b..4c1d8243 100644 --- a/lib/features/assessment/config/reach/reach.dart +++ b/lib/features/assessment/config/reach/reach.dart @@ -1,6 +1,10 @@ +import 'package:amplify_core/src/types/temporal/temporal_date.dart'; import 'package:visualpt/features/assessment/view/_assessments.dart'; import 'package:visualpt/features/inference/constants/model_file.dart'; +import 'package:visualpt/models/Assessment.dart'; import 'package:visualpt/models/AssessmentType.dart'; +import 'package:visualpt/models/Gender.dart'; +import 'package:visualpt/models/Norm.dart'; class REACH implements BaseAssessment { @override @@ -25,6 +29,9 @@ class REACH implements BaseAssessment { @override int get trials => 1; + @override + int get trialDuration => 30; + @override List get conditionNames => ["Reach Forward"]; @@ -53,4 +60,34 @@ class REACH implements BaseAssessment { @override bool get isAvailable => false; + + @override + Future?> getNorms(TemporalDate dob, Gender gender) { + // TODO: implement getNorm + throw UnimplementedError(); + } + + @override + Future setNorm( + TemporalDate dob, Gender gender, int seqNum, double score, int trials) { + // TODO: implement setNorm + throw UnimplementedError(); + } + + @override + List calculateScores(Assessment assessment) { + // TODO: implement calculateScores + throw UnimplementedError(); + } + + @override + String getNormativeMessage(Assessment assessment, List? norms) { + // TODO: implement getNormativeMessage + throw UnimplementedError(); + } + + @override + bool isComplete(args) { + return false; + } } diff --git a/lib/features/assessment/service/assessment_utils.dart b/lib/features/assessment/service/assessment_utils.dart index f6949e22..ac8d68aa 100644 --- a/lib/features/assessment/service/assessment_utils.dart +++ b/lib/features/assessment/service/assessment_utils.dart @@ -1,6 +1,6 @@ import 'package:visualpt/features/assessment/config/berg/berg.dart'; import 'package:visualpt/features/assessment/config/ctsib/ctsib.dart'; -import 'package:visualpt/features/assessment/config/ctsib_collect/ctsib_collect.dart'; +import 'package:visualpt/features/assessment/config/dev/dev.dart'; import 'package:visualpt/features/assessment/config/fist/fist.dart'; import 'package:visualpt/features/assessment/config/gait/gait.dart'; import 'package:visualpt/features/assessment/config/mctsib/mctsib.dart'; @@ -21,8 +21,6 @@ class AssessmentUtils { return AssessmentView(CTSIB()); case AssessmentType.MCTSIB: return AssessmentView(MCTSIB()); - case AssessmentType.CTSIB_COLLECT: - return AssessmentView(CTSIB_COLLECT()); case AssessmentType.BERG: return AssessmentView(BERG()); case AssessmentType.GAIT: @@ -31,6 +29,8 @@ class AssessmentUtils { return AssessmentView(REACH()); case AssessmentType.FIST: return AssessmentView(FIST()); + case AssessmentType.DEV: + return AssessmentView(DEV()); } } diff --git a/lib/features/assessment/types.dart b/lib/features/assessment/types.dart index 5bef330b..623e4c87 100644 --- a/lib/features/assessment/types.dart +++ b/lib/features/assessment/types.dart @@ -1,7 +1,7 @@ import 'package:visualpt/features/assessment/service/form_helper.dart'; import 'package:visualpt/models/ModelProvider.dart'; -class AssessmentMeta { +class TrialMeta { final String dateOfBirth; final Gender gender; final FallHistory fallHistory; @@ -11,7 +11,7 @@ class AssessmentMeta { final int condition; final int trial; - const AssessmentMeta( + const TrialMeta( this.dateOfBirth, this.gender, this.fallHistory, @@ -28,7 +28,7 @@ class AssessmentMeta { static fromCSV(String removeAt) { final parts = removeAt.split(","); - return AssessmentMeta( + return TrialMeta( parts[0], FormHelper.genderParse(parts[1]), FormHelper.fallHistoryParse(parts[2]), diff --git a/lib/features/assessment/view/_assessments.dart b/lib/features/assessment/view/_assessments.dart index bb2ee469..c8ec5f48 100644 --- a/lib/features/assessment/view/_assessments.dart +++ b/lib/features/assessment/view/_assessments.dart @@ -1,3 +1,4 @@ +import 'package:amplify_flutter/amplify_flutter.dart'; import 'package:visualpt/features/inference/constants/model_file.dart'; import 'package:visualpt/models/ModelProvider.dart'; // import 'package:visualpt/features/assessment/view/gait/gait.dart'; @@ -13,6 +14,7 @@ abstract class BaseAssessment { int get conditions; int get trials; + int get trialDuration; List get conditionNames; List> get conditionInstructions; List> get conditionInference; @@ -21,4 +23,74 @@ abstract class BaseAssessment { Map, String> get formFields; bool get isTrainingData; bool get isAvailable; + + Future?> getNorms(TemporalDate dob, Gender gender) { + return Future.value(null); + } + + Future setNorm( + TemporalDate dob, Gender gender, int seqNum, double score, int trials) { + return Future.value(); + } + + List calculateScores(Assessment assessment) { + return List.filled(0, 0.0); + } + + String getNormativeMessage(Assessment assessment, List? norms) { + return "Default Normative Message"; + } + + // Controls video recording, if returns true, the video will stop recording + bool isComplete(dynamic args) { + return false; + } +} + +void updateNorms( + BaseAssessment assessmentMeta, Assessment assessment, Subject subject) { + final equilibriumScores = assessmentMeta.calculateScores(assessment); + int seqNum = 0; + for (var score in equilibriumScores) { + assessmentMeta.setNorm( + subject.dob, subject.gender, seqNum, score, assessmentMeta.trials); + seqNum++; + } +} + +AgeRange getAgeEnumByTemporalDate(TemporalDate birthDate) { + DateTime birthDateTime = + birthDate.getDateTime(); // Assuming getDateTime() gives a DateTime object + DateTime today = DateTime.now(); + + int age = today.year - birthDateTime.year; + + // Check if the birthday hasn't occurred yet this year + if (today.month < birthDateTime.month || + (today.month == birthDateTime.month && today.day < birthDateTime.day)) { + age--; + } + + // Determine the AgeRange based on the calculated age + if (age >= 0 && age <= 5) { + return AgeRange.BABY_0_5; + } else if (age >= 6 && age <= 12) { + return AgeRange.CHILD_6_12; + } else if (age >= 13 && age <= 19) { + return AgeRange.TEEN_13_19; + } else if (age >= 20 && age <= 39) { + return AgeRange.ADULT_20_39; + } else if (age >= 40 && age <= 64) { + return AgeRange.ADULT_40_64; + } else if (age >= 65 && age <= 69) { + return AgeRange.SENIOR_65_69; + } else if (age >= 70 && age <= 79) { + return AgeRange.SENIOR_70_79; + } else if (age >= 80 && age <= 84) { + return AgeRange.SENIOR_80_84; + } else if (age >= 85 && age <= 89) { + return AgeRange.SENIOR_85_89; + } else { + throw ArgumentError('Age out of range for defined AgeRange enums'); + } } diff --git a/lib/features/assessment/view/assessment_form.dart b/lib/features/assessment/view/assessment_form.dart index ca0d08f5..2e93b7ac 100644 --- a/lib/features/assessment/view/assessment_form.dart +++ b/lib/features/assessment/view/assessment_form.dart @@ -64,16 +64,17 @@ class _AssessmentFormState extends State { final user = BlocProvider.of(context).state.user as User; if (_formKey.currentState!.validate()) { _formKey.currentState!.save(); - BlocProvider.of(context).add(AssessmentInit( - FormHelper.createSubject( - user.id, - firstNameController.text, - lastNameController.text, - dobController.text, - genderController.text, - heightController.text, - user.email, - fallHistoryController.text))); + final subject = FormHelper.createSubject( + user.id, + firstNameController.text, + lastNameController.text, + dobController.text, + genderController.text, + heightController.text, + user.email, + fallHistoryController.text); + BlocProvider.of(context) + .add(AssessmentInit(subject, storeData)); } } catch (e) { print(e); @@ -137,29 +138,26 @@ class _AssessmentFormState extends State { controller: fallHistoryController, inputPopup: fallHistoryPopup, ), - // Container( - // alignment: Alignment.center, - // margin: const EdgeInsets.symmetric( - // vertical: 8.0, horizontal: 20.0), - // padding: const EdgeInsets.all(2.0), - // decoration: const BoxDecoration( - // color: Styles.white, - // borderRadius: Styles.borderRadiusM), - // child: Row( - // mainAxisAlignment: MainAxisAlignment.center, - // mainAxisSize: MainAxisSize.min, - // children: [ - // Text( - // "Store Data", - // ), - // CupertinoSwitch( - // value: storeData, - // onChanged: (bool value) { - // setState(() { - // storeData = value; - // }); - // }), - // ])), + GestureDetector( + onTap: () { + setState(() { + storeData = !storeData; + }); + }, + child: Container( + alignment: Alignment.center, + margin: const EdgeInsets.symmetric( + vertical: 8.0, horizontal: 20.0), + padding: const EdgeInsets.all(16.0), + decoration: BoxDecoration( + color: storeData ? Styles.blue : Styles.gray, + borderRadius: Styles.borderRadiusM), + child: const Text( + "Store Data", + style: Styles.placeholderStyle, + ), + ), + ), CupertinoButton( onPressed: handleAutofill, child: const Text("Autofill", diff --git a/lib/features/assessment/view/components/assessment_capture.dart b/lib/features/assessment/view/components/assessment_capture.dart index 83b59048..a4fc0bac 100644 --- a/lib/features/assessment/view/components/assessment_capture.dart +++ b/lib/features/assessment/view/components/assessment_capture.dart @@ -13,8 +13,9 @@ class AssessmentCapture extends StatelessWidget { Widget build(BuildContext context) { return BaseView( pageTitle: - "${state.assessment.type.name} ${state.assessment.conditionNames[state.meta.condition]} \n Trial ${state.meta.trial + 1}", + "${state.meta.type.name} ${state.meta.conditionNames[state.trialMeta.condition]} \n Trial ${state.trialMeta.trial + 1}", instructions: AssessmentInstructions(state), + poppable: false, child: CaptureView( state, )); diff --git a/lib/features/assessment/view/components/assessment_card.dart b/lib/features/assessment/view/components/assessment_card.dart index b7b0a823..6729a23b 100644 --- a/lib/features/assessment/view/components/assessment_card.dart +++ b/lib/features/assessment/view/components/assessment_card.dart @@ -1,9 +1,7 @@ import 'package:flutter/cupertino.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:visualpt/core/views/app_nav.dart'; import 'package:visualpt/core/views/styles.dart'; import 'package:visualpt/features/assessment/view/_assessments.dart'; -import 'package:visualpt/features/auth/bloc/auth_bloc.dart'; class AssessmentCard extends StatelessWidget { final BaseAssessment assessment; @@ -15,16 +13,16 @@ class AssessmentCard extends StatelessWidget { @override Widget build(BuildContext context) { final nav = AppNav(context); - final isAdmin = (BlocProvider.of(context).state as AuthSuccess) - .user - .email - .toLowerCase() - .contains("tim.richardson@fyzicalhq.com") || - (BlocProvider.of(context).state as AuthSuccess) - .user - .email - .toLowerCase() - .contains("charlesrichardsonusa@gmail.com"); + // final isAdmin = (BlocProvider.of(context).state as AuthSuccess) + // .user + // .email + // .toLowerCase() + // .contains("tim.richardson@fyzicalhq.com") || + // (BlocProvider.of(context).state as AuthSuccess) + // .user + // .email + // .toLowerCase() + // .contains("charlesrichardsonusa@gmail.com"); return GestureDetector( onTap: () => showCupertinoModalPopup( @@ -52,78 +50,77 @@ class AssessmentCard extends StatelessWidget { textAlign: TextAlign.center, ), ), - Row(mainAxisSize: MainAxisSize.max, children: [ - const Spacer(), - Column( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: GestureDetector( - onTap: () => assessment.isAvailable - ? nav.toPath( - path: "/assessment", - args: assessment.type) - : null, - child: Container( - padding: const EdgeInsets.all(4.0), - decoration: BoxDecoration( - color: Styles.actionPrimary.withAlpha( - assessment.isAvailable ? 255 : 128), - border: Border.all( - color: Styles.black, - width: 1, - ), - borderRadius: Styles.borderRadiusM, - ), - child: const Icon( - CupertinoIcons.play_arrow, - color: Styles.white, - size: 60, + + Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: GestureDetector( + onTap: () => assessment.isAvailable + ? nav.toPath( + path: "/assessment", + args: assessment.type) + : null, + child: Container( + padding: const EdgeInsets.all(4.0), + decoration: BoxDecoration( + color: Styles.actionPrimary.withAlpha( + assessment.isAvailable ? 255 : 128), + border: Border.all( + color: Styles.black, + width: 1, ), + borderRadius: Styles.borderRadiusM, ), - )), - const Text("Capture", style: Styles.heading3), - ], - ), - const Spacer(), - isAdmin - ? Column( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: GestureDetector( - onTap: () => nav.toPath( - path: "/validate", - args: assessment.type), - child: Container( - padding: const EdgeInsets.all(4.0), - decoration: BoxDecoration( - color: Styles.actionSecondary - .withAlpha( - assessment.isAvailable - ? 255 - : 128), - border: Border.all( - color: Styles.black, - width: 1, - ), - borderRadius: Styles.borderRadiusM, - ), - child: const Icon( - CupertinoIcons.eye, - color: Styles.black, - size: 60, - ), - ), - ), + child: const Icon( + CupertinoIcons.play_arrow, + color: Styles.white, + size: 60, ), - const Text("Validate", - style: Styles.heading3) - ], - ) - : Container(), - const Spacer(), - ]), + ), + )), + const Text("Capture", style: Styles.heading3), + ], + ), + // const Spacer(), + // isAdmin + // ? Column( + // children: [ + // Padding( + // padding: const EdgeInsets.all(8.0), + // child: GestureDetector( + // onTap: () => nav.toPath( + // path: "/validate", + // args: assessment.type), + // child: Container( + // padding: const EdgeInsets.all(4.0), + // decoration: BoxDecoration( + // color: Styles.actionSecondary + // .withAlpha( + // assessment.isAvailable + // ? 255 + // : 128), + // border: Border.all( + // color: Styles.black, + // width: 1, + // ), + // borderRadius: Styles.borderRadiusM, + // ), + // child: const Icon( + // CupertinoIcons.eye, + // color: Styles.black, + // size: 60, + // ), + // ), + // ), + // ), + // const Text("Validate", + // style: Styles.heading3) + // ], + // ) + // : Container(), + // const Spacer(), + const SizedBox(height: 20), Text( assessment.details, @@ -147,52 +144,64 @@ class AssessmentCard extends StatelessWidget { ), borderRadius: Styles.borderRadiusM, ), - padding: const EdgeInsets.symmetric(horizontal: 2.0, vertical: 8.0), + padding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 4.0), margin: const EdgeInsets.symmetric(vertical: 2.0), child: Row( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.start, children: [ SizedBox( - height: MediaQuery.of(context).size.height / 12, - width: MediaQuery.of(context).size.height / 12, + height: MediaQuery.of(context).size.height / 10, + width: MediaQuery.of(context).size.height / 10, child: Container( - decoration: const BoxDecoration( - borderRadius: Styles.borderRadiusM, - ), - child: Padding( - padding: const EdgeInsets.all(2), - child: Image( + margin: const EdgeInsets.all(2), + child: Image( image: AssetImage(assessment.imagePath), fit: BoxFit.contain, - ), - ), + frameBuilder: + (context, child, frame, wasSynchronouslyLoaded) { + return ClipRRect( + borderRadius: Styles.borderRadiusM, + child: child, + ); + }, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) { + return child; + } + return const Icon( + CupertinoIcons.question_circle_fill, + color: Styles.black, + size: 60, + ); + }, + errorBuilder: (context, error, stackTrace) { + return const Icon( + CupertinoIcons.question_circle_fill, + color: Styles.black, + size: 60, + ); + }), ), ), Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0), - child: Text(assessment.type.name.replaceAll("_", " "), + child: Padding( + padding: const EdgeInsets.all(4.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text(assessment.type.name.replaceAll("_", " "), style: Styles.title), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0), - child: Text(assessment.name, style: Styles.heading3), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Text( + Text(assessment.name, style: Styles.heading3), + Text( assessment.normalDuration, style: Styles.body, textAlign: TextAlign.end, ), - ), - ], + ], + ), ), ) ]), diff --git a/lib/features/assessment/view/components/assessment_instructions.dart b/lib/features/assessment/view/components/assessment_instructions.dart index 19f7b037..97af18d7 100644 --- a/lib/features/assessment/view/components/assessment_instructions.dart +++ b/lib/features/assessment/view/components/assessment_instructions.dart @@ -10,7 +10,7 @@ class AssessmentInstructions extends StatelessWidget { @override Widget build(BuildContext context) { var formattedInstructionList = state - .assessment.conditionInstructions[state.meta.condition] + .meta.conditionInstructions[state.trialMeta.condition] .map((instruction) { return Padding( padding: const EdgeInsets.all(8.0), @@ -25,10 +25,10 @@ class AssessmentInstructions extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ const Spacer(), - Text(state.meta.assessment.name, style: Styles.title), + Text(state.trialMeta.assessment.name, style: Styles.title), const Spacer(), Text( - "Criteria ${(state.meta.condition + 1).toString()} of ${state.assessment.conditions.toString()}", + "Condition ${(state.trialMeta.condition + 1).toString()} of ${state.meta.conditions.toString()}", style: Styles.subactionText), const Spacer(), Container( @@ -37,7 +37,7 @@ class AssessmentInstructions extends StatelessWidget { color: Styles.black, ), Text( - state.assessment.conditionNames[state.meta.condition], + state.meta.conditionNames[state.trialMeta.condition], style: Styles.heading1, textAlign: TextAlign.center, ), diff --git a/lib/features/auth/bloc/auth_bloc.dart b/lib/features/auth/bloc/auth_bloc.dart index 71e9c2ee..d94f8fb0 100644 --- a/lib/features/auth/bloc/auth_bloc.dart +++ b/lib/features/auth/bloc/auth_bloc.dart @@ -68,8 +68,9 @@ class AuthBloc extends Bloc { // } // }); } catch (e) { - if (!isClosed && !emit.isDone) + if (!isClosed && !emit.isDone) { emit(AuthError(error: Exception("Failed to auto auth $e"))); + } } } diff --git a/lib/features/backend/bloc/backend_bloc.dart b/lib/features/backend/bloc/backend_bloc.dart index 534b88d1..d018cce7 100644 --- a/lib/features/backend/bloc/backend_bloc.dart +++ b/lib/features/backend/bloc/backend_bloc.dart @@ -5,7 +5,7 @@ import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:visualpt/features/backend/service/backend_repository.dart'; -import 'package:visualpt/features/timer/bloc/timer_bloc.dart'; +import 'package:visualpt/features/video/bloc/video_bloc.dart'; part 'backend_event.dart'; part 'backend_state.dart'; @@ -41,7 +41,8 @@ class BackendBloc extends Bloc { //TOOD: put this somewhere better void backendLoading() { - StreamSubscription timer = const Ticker().tick().listen((_) {}); + StreamSubscription timer = + const Ticker(duration: Duration(seconds: 30)).tick().listen((_) {}); timer.onData((data) { if (state is BackendOnline) { log("Ending timeout timer"); diff --git a/lib/features/email/bloc/email_bloc.dart b/lib/features/email/bloc/email_bloc.dart index 6a6de358..0d76eb65 100644 --- a/lib/features/email/bloc/email_bloc.dart +++ b/lib/features/email/bloc/email_bloc.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:http/http.dart'; +import 'package:visualpt/features/video/service/video_service.dart'; import 'package:visualpt/models/ModelProvider.dart'; import 'package:visualpt/features/email/service/email_service.dart'; @@ -20,17 +21,18 @@ class EmailBloc extends Bloc { ///Sends a curated single template of a basic Assessment with an attached PDF FutureOr _onSend(EmailSend event, Emitter emit) async { + final videos = await VideoService.getVideosInBase64(event.assessment); List> attachments = []; - for (int i = 0; i < event.videos.length; i++) { + for (int i = 0; i < videos.length; i++) { attachments.add({ 'filename': '${event.assessment.type.name}_condition${i + 1}_${event.subject.last_name}.mp4', - 'content': event.videos[i], + 'content': videos[i], 'type': 'video/mp4', 'disposition': 'attachment' }); } - print(attachments); + final Map templateParams = { 'patient_name': "${event.subject.first_name} ${event.subject.last_name}", 'patient_dob': event.subject.dob.toString(), diff --git a/lib/features/email/bloc/email_event.dart b/lib/features/email/bloc/email_event.dart index 1a26dbef..f7d16ea2 100644 --- a/lib/features/email/bloc/email_event.dart +++ b/lib/features/email/bloc/email_event.dart @@ -10,13 +10,11 @@ abstract class EmailEvent extends Equatable { class EmailSend extends EmailEvent { final Subject subject; final Assessment assessment; - final List videos; final Function() onSuccess; final Function() onFail; const EmailSend( {required this.subject, required this.assessment, - required this.videos, required this.onSuccess, required this.onFail}); diff --git a/lib/features/inference/bloc/inference_bloc.dart b/lib/features/inference/bloc/inference_bloc.dart index dc2140c2..8a39562b 100644 --- a/lib/features/inference/bloc/inference_bloc.dart +++ b/lib/features/inference/bloc/inference_bloc.dart @@ -12,8 +12,8 @@ part 'inference_state.dart'; //TODO: Add toggleable loggers to all blocs class InferenceBloc extends Bloc, InferenceState> { - InferenceBloc( - this.inferenceRepository, this.outputRepository, this.sampleRate) + InferenceBloc(this.inferenceRepository, this.outputRepository, + this.sampleRate, this.storeData) : super(InferenceLoading()) { on>(_onInit); on>(_onStart); @@ -27,6 +27,7 @@ class InferenceBloc final Repository inferenceRepository; final Repository? outputRepository; final int sampleRate; + final bool storeData; bool isReady = false; bool processing = false; @@ -44,6 +45,7 @@ class InferenceBloc FutureOr _onStart( InferenceStart event, Emitter emit) async { + inferenceRepository.reset(); //Handles the reset case _subscription = inferenceRepository.stream.listen((data) { if (isReady) { add(InferenceInput(data.id, input: data, params: event.params)); @@ -78,7 +80,7 @@ class InferenceBloc try { if (event.output != null) { outputRepository?.add(event.output!); - if (storageBacklog > 0) { + if (storeData && storageBacklog > 0) { //TODO: Delegate to InferenceRepository to check if all conditions are being met and store data //TODO: Add a path to the repository to store the data storageBacklog--; diff --git a/lib/features/inference/service/face_detection/face_detection_service.dart b/lib/features/inference/service/face_detection/face_detection_service.dart index 0ce48f62..291c899c 100644 --- a/lib/features/inference/service/face_detection/face_detection_service.dart +++ b/lib/features/inference/service/face_detection/face_detection_service.dart @@ -65,10 +65,10 @@ class FaceDetection extends AiModel { final outputTensors = interpreter!.getOutputTensors(); - outputTensors.forEach((tensor) { + for (var tensor in outputTensors) { outputShapes.add(tensor.shape); outputTypes.add(tensor.type); - }); + } } catch (e) { log('Error while creating interpreter: $e'); } diff --git a/lib/features/inference/service/face_mesh/face_mesh_service.dart b/lib/features/inference/service/face_mesh/face_mesh_service.dart index 9865b51e..cff59cdc 100644 --- a/lib/features/inference/service/face_mesh/face_mesh_service.dart +++ b/lib/features/inference/service/face_mesh/face_mesh_service.dart @@ -37,10 +37,10 @@ class FaceMesh extends AiModel { final outputTensors = interpreter!.getOutputTensors(); - outputTensors.forEach((tensor) { + for (var tensor in outputTensors) { outputShapes.add(tensor.shape); outputTypes.add(tensor.type); - }); + } } catch (e) { log('Error while creating interpreter: $e'); } diff --git a/lib/features/inference/service/hands/hands_service.dart b/lib/features/inference/service/hands/hands_service.dart index a7a14a80..22c571e1 100644 --- a/lib/features/inference/service/hands/hands_service.dart +++ b/lib/features/inference/service/hands/hands_service.dart @@ -39,10 +39,10 @@ class Hands extends AiModel { final outputTensors = interpreter!.getOutputTensors(); - outputTensors.forEach((tensor) { + for (var tensor in outputTensors) { outputShapes.add(tensor.shape); outputTypes.add(tensor.type); - }); + } } catch (e) { log('Error while creating interpreter: $e'); } diff --git a/lib/features/inference/service/pose_classification/pose_class_output.dart b/lib/features/inference/service/pose_classification/pose_class_output.dart index baca8181..894d3b92 100644 --- a/lib/features/inference/service/pose_classification/pose_class_output.dart +++ b/lib/features/inference/service/pose_classification/pose_class_output.dart @@ -4,9 +4,9 @@ import 'package:visualpt/features/inference/service/ai_model.dart'; final Map classifications = { 0: "static", - 1: "arms uncrossed", + 1: "arms unfold", 2: "trunk flex", - 3: "feet separated", + 3: "legs open", -1: "invalid", }; @@ -38,14 +38,14 @@ class PoseClassificationOutput extends AiModelOutput { switch (name) { case "static": return Styles.green; - case "arms uncrossed": + case "arms unfold": return Styles.yellow; case "trunk flex": return Styles.yellow; - case "feet separated": + case "legs open": return Styles.yellow; default: - return Styles.blue; + return Styles.red; } } diff --git a/lib/features/inference/service/pose_classification/pose_class_service.dart b/lib/features/inference/service/pose_classification/pose_class_service.dart index d762dec8..8cc5c3be 100644 --- a/lib/features/inference/service/pose_classification/pose_class_service.dart +++ b/lib/features/inference/service/pose_classification/pose_class_service.dart @@ -92,13 +92,9 @@ class PoseClassification } final List> data = input.output; final processedInput = - data.sublist(0, 33).map((e) => e.sublist(0, 2)).toList(); + data.sublist(0, 33).map((e) => e.sublist(0, 5)).toList(); - final normalizedInput = normalizeDetection(processedInput); - if (normalizedInput == null) { - return null; - } - final inputs = [normalizedInput as Object]; + final inputs = [processedInput as Object]; TensorBuffer output = TensorBufferFloat(outputShapes[0]); diff --git a/lib/features/inference/service/pose_detection/pose_detection_classification_repository.dart b/lib/features/inference/service/pose_detection/pose_detection_classification_repository.dart index 3700f454..cd0313a0 100644 --- a/lib/features/inference/service/pose_detection/pose_detection_classification_repository.dart +++ b/lib/features/inference/service/pose_detection/pose_detection_classification_repository.dart @@ -9,7 +9,7 @@ class DetectionClassificationRepository extends Repository { final ModelInferenceService _poseClassService = ModelInferenceService(); final IsolateUtils _classificationIsolateUtils = IsolateUtils(); - final AssessmentMeta assessmentMeta; + final TrialMeta assessmentMeta; DetectionClassificationRepository(this.assessmentMeta) : super(assessmentMeta); diff --git a/lib/features/inference/service/pose_detection/pose_detection_output.dart b/lib/features/inference/service/pose_detection/pose_detection_output.dart index ed33aeae..d38094dc 100644 --- a/lib/features/inference/service/pose_detection/pose_detection_output.dart +++ b/lib/features/inference/service/pose_detection/pose_detection_output.dart @@ -61,6 +61,7 @@ class PoseDetectionOutput extends AiModelOutput { } //Computes the latitudidal (left/right (x,y)) and longitudidal (y, z) angles of the pose +//longitudinal angle is based on synthetic data, so it is not accurate and should not be used until further testing is done static (double, double) computeAngles(List<(double, double, double)> points) { double sumX = 0, sumY = 0, diff --git a/lib/features/inference/view/camera_painters/ctsib_overlays.dart b/lib/features/inference/view/camera_painters/ctsib_overlays.dart index 032096f1..c485d56e 100644 --- a/lib/features/inference/view/camera_painters/ctsib_overlays.dart +++ b/lib/features/inference/view/camera_painters/ctsib_overlays.dart @@ -2,7 +2,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:visualpt/features/inference/service/pose_detection/pose_detection_output.dart'; -const double MAX_SWAY_ANGLE = 12; +const double MAX_SWAY_ANGLE = 8; bool validateInequalities( double minA, double maxA, double minB, double maxB, double diff) { diff --git a/lib/features/inference/view/inference_indicator.dart b/lib/features/inference/view/inference_indicator.dart index eef18e02..a08e803e 100644 --- a/lib/features/inference/view/inference_indicator.dart +++ b/lib/features/inference/view/inference_indicator.dart @@ -7,14 +7,12 @@ class InferenceIndicator extends StatefulWidget { required this.icon, required this.color, required this.type, - required this.message, - this.formattedTextWidget = + this.child = const Text("...", style: TextStyle(fontWeight: FontWeight.w300))}); final IconData icon; final Color color; final String type; - final String message; - final Text formattedTextWidget; + final Text child; @override State createState() => _InferenceIndicatorState(); @@ -40,9 +38,9 @@ class _InferenceIndicatorState extends State { color: widget.color.withAlpha(128), border: Border.all()), child: Icon(widget.icon, size: 16, color: Styles.black)), - Text("${widget.type} "), + Text("${widget.type} ", style: Styles.heading2), const Spacer(), - widget.formattedTextWidget, + widget.child, ]), )), ); diff --git a/lib/features/inference/view/pose_classification_indicator.dart b/lib/features/inference/view/pose_classification_indicator.dart index bbb20f52..6222cd75 100644 --- a/lib/features/inference/view/pose_classification_indicator.dart +++ b/lib/features/inference/view/pose_classification_indicator.dart @@ -27,28 +27,38 @@ class PoseClassificationIndicator extends StatelessWidget { icon: icon, color: Styles.green, type: type, - message: "Running", - formattedTextWidget: Text(state.response?.name ?? "No pose detected", + child: Text(state.response?.name ?? "No subject", style: TextStyle( - fontWeight: FontWeight.bold, color: state.response?.getColor())), + color: state.response?.getColor() ?? Styles.red, + fontSize: 22, + fontFamily: 'monospace')), ); } Widget onStandby(BuildContext context, InferenceStandby state) { return const InferenceIndicator( - icon: icon, color: Styles.yellow, type: type, message: "Standby"); + icon: icon, + color: Styles.yellow, + type: type, + child: Text("Standby", style: Styles.heading2)); } Widget onLoad(BuildContext context, InferenceLoading state) { return const InferenceIndicator( - icon: icon, color: Styles.inactiveGray, type: type, message: "Loading"); + icon: icon, + color: Styles.inactiveGray, + type: type, + child: Text("Loading", style: Styles.heading2)); } Widget onError(BuildContext context, InferenceError state) { return const InferenceIndicator( - icon: icon, color: Styles.errorRed, type: type, message: "Error"); + icon: icon, + color: Styles.errorRed, + type: type, + child: Text("Error", style: Styles.heading2)); } } diff --git a/lib/features/inference/view/pose_detection_indicator.dart b/lib/features/inference/view/pose_detection_indicator.dart index a2cb1c16..c9f5fa8e 100644 --- a/lib/features/inference/view/pose_detection_indicator.dart +++ b/lib/features/inference/view/pose_detection_indicator.dart @@ -52,7 +52,7 @@ class PoseDetectionIndicator extends StatelessWidget { state.response!.landmarks.sublist(0, 34), const Size(256, 256)) : null; final color = offsets != null - ? offsets.degrees.abs() < MAX_SWAY_ANGLE + ? offsets.degrees.abs() < MAX_SWAY_ANGLE / 2 ? Styles.green : Styles.yellow : Styles.red; @@ -60,28 +60,40 @@ class PoseDetectionIndicator extends StatelessWidget { icon: icon, color: Styles.green, type: type, - message: "Running", - formattedTextWidget: Text( - "${offsets?.degrees.toStringAsFixed(1) ?? "0.0"}°", - style: TextStyle(fontWeight: FontWeight.bold, color: color)), + child: Text("${offsets?.degrees.toStringAsFixed(1) ?? "-.--"}°", + style: TextStyle( + fontWeight: FontWeight.bold, + color: color, + fontSize: 22, + fontFamily: 'monospace')), ); } Widget onStandby(BuildContext context, InferenceStandby state) { return const InferenceIndicator( - icon: icon, color: Styles.yellow, type: type, message: "Standby"); + icon: icon, + color: Styles.yellow, + type: type, + child: Text("Standby", style: Styles.heading2)); } Widget onLoad(BuildContext context, InferenceLoading state) { return const InferenceIndicator( - icon: icon, color: Styles.inactiveGray, type: type, message: "Loading"); + icon: icon, + color: Styles.inactiveGray, + type: type, + child: Text("Loading", style: Styles.heading2), + ); } Widget onError(BuildContext context, InferenceError state) { return const InferenceIndicator( - icon: icon, color: Styles.errorRed, type: type, message: "Error"); + icon: icon, + color: Styles.errorRed, + type: type, + child: Text("Error", style: Styles.heading2)); } } diff --git a/lib/features/timer/bloc/timer_bloc.dart b/lib/features/timer/bloc/timer_bloc.dart deleted file mode 100644 index 8ae6a876..00000000 --- a/lib/features/timer/bloc/timer_bloc.dart +++ /dev/null @@ -1,69 +0,0 @@ -///Sourced from https://github.com/felangel/bloc/tree/master/examples/flutter_timer - -import 'dart:async'; - -import 'package:bloc_concurrency/bloc_concurrency.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -part 'timer_event.dart'; -part 'timer_state.dart'; - -//TODO This could change depending on the test, may need to specify this when creating the bloc in the future -const Duration _minDuration = Duration(seconds: 0); -const Duration _maxDuration = Duration(seconds: 30); - -//TODO: This would probbaly perform better if it was just a start time and the difference from that start time -class TimerBloc extends Bloc { - TimerBloc() - : _ticker = const Ticker(), - super(const TimerInitial(_minDuration)) { - on(_onStarted); - on(_onReset); - on<_TimerTicked>(_onTicked, transformer: droppable()); - } - - final Ticker _ticker; - StreamSubscription? _tickerSubscription; - - @override - Future close() { - _tickerSubscription?.cancel(); - return super.close(); - } - - void _onStarted(TimerStarted event, Emitter emit) { - emit(const TimerRunInProgress(Duration(milliseconds: 0))); - _tickerSubscription?.cancel(); - _tickerSubscription = _ticker - .tick() - .listen((duration) => add(_TimerTicked(duration: duration))); - } - - void _onReset(TimerReset event, Emitter emit) { - _tickerSubscription?.cancel(); - emit(const TimerInitial(_minDuration)); - } - - void _onTicked(_TimerTicked event, Emitter emit) { - emit( - event.duration < _maxDuration - ? TimerRunInProgress(event.duration) - : const TimerRunComplete(), - ); - } -} - -//Stream class that powers the entire BLOC -class Ticker { - static const increment = 10; - const Ticker(); - Stream tick() { - return Stream.periodic( - const Duration(milliseconds: increment), - (x) => Duration(milliseconds: increment * x), - ).takeWhile( - (duration) => duration <= _maxDuration, - ); - } -} diff --git a/lib/features/timer/bloc/timer_event.dart b/lib/features/timer/bloc/timer_event.dart deleted file mode 100644 index 88f700b9..00000000 --- a/lib/features/timer/bloc/timer_event.dart +++ /dev/null @@ -1,18 +0,0 @@ -part of 'timer_bloc.dart'; - -abstract class TimerEvent { - const TimerEvent(); -} - -class TimerStarted extends TimerEvent { - const TimerStarted(); -} - -class TimerReset extends TimerEvent { - const TimerReset(); -} - -class _TimerTicked extends TimerEvent { - const _TimerTicked({required this.duration}); - final Duration duration; -} diff --git a/lib/features/timer/bloc/timer_state.dart b/lib/features/timer/bloc/timer_state.dart deleted file mode 100644 index 052c2987..00000000 --- a/lib/features/timer/bloc/timer_state.dart +++ /dev/null @@ -1,36 +0,0 @@ -part of 'timer_bloc.dart'; - -abstract class TimerState extends Equatable { - const TimerState(this.duration); - final Duration duration; - - @override - List get props => [duration.inMilliseconds]; -} - -class TimerInitial extends TimerState { - const TimerInitial(super.duration); - - @override - String toString() => 'TimerInitial { duration: $duration }'; -} - -class TimerRunInProgress extends TimerState { - const TimerRunInProgress(super.duration); - - @override - List get props => [duration.inMilliseconds]; - - @override - String toString() => 'TimerRunInProgress { duration: $duration }'; -} - -class TimerRunComplete extends TimerState { - const TimerRunComplete() : super(_maxDuration); - - @override - String toString() => 'TimerRunComplete { duration: $duration }'; - - @override - List get props => [duration.inMilliseconds]; -} diff --git a/lib/features/timer/view/timer_presenter.dart b/lib/features/timer/view/timer_presenter.dart deleted file mode 100644 index 8b137891..00000000 --- a/lib/features/timer/view/timer_presenter.dart +++ /dev/null @@ -1 +0,0 @@ - diff --git a/lib/features/video/bloc/video_bloc.dart b/lib/features/video/bloc/video_bloc.dart index f178beab..f9b3c9f1 100644 --- a/lib/features/video/bloc/video_bloc.dart +++ b/lib/features/video/bloc/video_bloc.dart @@ -13,14 +13,21 @@ part 'video_event.dart'; part 'video_state.dart'; class VideoBloc extends Bloc { - VideoBloc(this.videoDetectionRepo) : super(const VideoLoading()) { + VideoBloc(this.videoDetectionRepo, this.totalDuration) + : _ticker = Ticker(duration: totalDuration), + super(const VideoLoading()) { on(onInit); on(onPlay); + on(onPause); + on(onTick); on(onStop); } final VideoDetectionRepository videoDetectionRepo; late CameraController? controller; late Duration videoDuration = const Duration(seconds: 0); + final Duration totalDuration; + final Ticker _ticker; + StreamSubscription? _tickerSubscription; amplify.TemporalDateTime? _start; FutureOr onInit(VideoInit event, Emitter emit) async { @@ -35,6 +42,7 @@ class VideoBloc extends Bloc { imageFormatGroup: ImageFormatGroup.bgra8888, ); await controller!.initialize(); + await controller?.lockCaptureOrientation(); emit(VideoStandby(controller!)); } on CameraException catch (e) { //TODO create a popup for these error codes @@ -85,24 +93,46 @@ class VideoBloc extends Bloc { gyroZ: videoDetectionRepo.currentGyro.z); videoDetectionRepo.add(DeviceCapture(id, image, measurements)); }); - emit(VideoRecording(controller!, _start!)); + _tickerSubscription?.cancel(); + _tickerSubscription = + _ticker.tick().listen((duration) => add(VideoTick(duration: duration))); + } + + FutureOr onPause(VideoPause event, Emitter emit) async { + //Prevent the video from adding any more resources into the repo + //Pause the timer + _tickerSubscription?.pause(); + emit(VideoPaused(controller!, videoDuration)); + } + + FutureOr onTick(VideoTick event, Emitter emit) async { + emit( + event.duration < totalDuration + ? VideoRecording(controller!, _start!, event.duration, false) + : VideoRecording(controller!, _start!, totalDuration, true), + ); } FutureOr onStop(VideoStop event, Emitter emit) async { + _tickerSubscription?.cancel(); if (_start == null) { print("There is no start time to label the video."); } else { videoDuration = DateTime.now().difference(_start!.getDateTimeInUtc()); - if (controller != null && controller!.value.isStreamingImages) { + if (controller != null && + (controller!.value.isStreamingImages || + controller!.value.isRecordingVideo)) { + final file = await controller!.stopVideoRecording(); if (event.reset) { emit(VideoStandby(controller!)); + return; } - final file = await controller!.stopVideoRecording(); videoDetectionRepo.saveVideo( _start!.getDateTimeInUtc().millisecondsSinceEpoch.toString(), file); - emit(VideoStopped(file, videoDuration)); + emit(VideoEnded(file, videoDuration)); } else { log("Video was already stopped $controller ${controller!.value.isRecordingVideo}"); + emit(VideoEnded(null, videoDuration)); } emit(const VideoLoading()); controller!.dispose(); @@ -126,6 +156,21 @@ class VideoBloc extends Bloc { // } } +class Ticker { + static const increment = 10; + final Duration duration; + const Ticker({required this.duration}); + Stream tick() { + return Stream.periodic( + const Duration(milliseconds: increment), + (x) => Duration(milliseconds: increment * x), + ).takeWhile( + (d) => d <= duration, + ); + } +} + + //TODO create an onFocus state for this in the view file // void onFocus(TapDownDetails details, BoxConstraints constraints) { // if (cameraController == null) { diff --git a/lib/features/video/bloc/video_event.dart b/lib/features/video/bloc/video_event.dart index 64090689..67a4eea4 100644 --- a/lib/features/video/bloc/video_event.dart +++ b/lib/features/video/bloc/video_event.dart @@ -6,18 +6,33 @@ abstract class VideoEvent { } class VideoInit extends VideoEvent { + //Loads the video resources const VideoInit(); } class VideoPlay extends VideoEvent { + //Starts streaming the video data into the view and repo const VideoPlay(); } +class VideoPause extends VideoEvent { + //Pauses the video stream + const VideoPause(); +} + +class VideoTick extends VideoEvent { + //Incremental time update + const VideoTick({required this.duration}); + final Duration duration; +} + class VideoRestart extends VideoEvent { + //Resets the video stream, repository and view const VideoRestart(); } class VideoStop extends VideoEvent { + //Stops the video stream, repository and view final bool reset; const VideoStop({required this.reset}); } diff --git a/lib/features/video/bloc/video_state.dart b/lib/features/video/bloc/video_state.dart index 1f150bf1..5ef198db 100644 --- a/lib/features/video/bloc/video_state.dart +++ b/lib/features/video/bloc/video_state.dart @@ -3,11 +3,13 @@ part of 'video_bloc.dart'; @immutable abstract class VideoState extends Equatable { final IconData icon; - const VideoState(this.icon); + final Duration duration; + final bool pauseFlag; + const VideoState(this.icon, this.duration, [this.pauseFlag = false]); } class VideoLoading extends VideoState { - const VideoLoading() : super(CupertinoIcons.hourglass); + const VideoLoading() : super(CupertinoIcons.hourglass, Duration.zero); @override List get props => []; @@ -16,37 +18,47 @@ class VideoLoading extends VideoState { class VideoRecording extends VideoState { final CameraController controller; final amplify.TemporalDateTime startTime; - VideoRecording(this.controller, this.startTime) + VideoRecording( + this.controller, this.startTime, Duration duration, bool pauseFlag) : assert(controller.value.isInitialized), - super(CupertinoIcons.square); + super(CupertinoIcons.square, duration, pauseFlag); @override - List get props => [controller]; + List get props => [controller, pauseFlag, duration]; } class VideoStandby extends VideoState { final CameraController controller; - const VideoStandby(this.controller) : super(CupertinoIcons.circle); + const VideoStandby(this.controller, {Duration? duration}) + : super(CupertinoIcons.circle, duration ?? Duration.zero); @override List get props => [controller]; } -class VideoStopped extends VideoState { - final XFile file; - final Duration videoDuration; - const VideoStopped(this.file, this.videoDuration) - : super(CupertinoIcons.stop); +class VideoPaused extends VideoState { + final CameraController controller; + const VideoPaused(this.controller, Duration duration) + : super(CupertinoIcons.pause, duration); + + @override + List get props => [controller, duration]; +} + +class VideoEnded extends VideoState { + final XFile? file; + const VideoEnded(this.file, Duration duration) + : super(CupertinoIcons.stop, duration); @override - List get props => [file, videoDuration]; + List get props => [file, duration]; } class VideoError extends VideoState { final Exception exception; const VideoError({ required this.exception, - }) : super(CupertinoIcons.restart); + }) : super(CupertinoIcons.restart, Duration.zero); @override List get props => [exception]; diff --git a/lib/features/video/service/video_detection_repository.dart b/lib/features/video/service/video_detection_repository.dart index 03bbb1cf..552cec13 100644 --- a/lib/features/video/service/video_detection_repository.dart +++ b/lib/features/video/service/video_detection_repository.dart @@ -29,7 +29,7 @@ class VideoDetectionRepository GyroscopeEvent currentGyro = GyroscopeEvent(0, 0, 0); UserAccelerometerEvent currentAccel = UserAccelerometerEvent(0, 0, 0); final String userID; - final AssessmentMeta assessmentMeta; + final TrialMeta assessmentMeta; VideoDetectionRepository(this.assessmentMeta, this.userID) : super(assessmentMeta) { diff --git a/lib/features/video/types.dart b/lib/features/video/types.dart index fab8bb0d..044ef898 100644 --- a/lib/features/video/types.dart +++ b/lib/features/video/types.dart @@ -33,7 +33,7 @@ class VPTDetection { final int imageHeight; final int imageWidth; //Clinical Meta - final AssessmentMeta assessmentMeta; + final TrialMeta assessmentMeta; //Detection Meta final DeviceMeta deviceMeta; final String inferredClassification; @@ -74,7 +74,7 @@ class VPTDetection { final version = int.parse(lines.removeAt(0)); final id = lines.removeAt(0); final imageMeta = lines.removeAt(0).split(","); - final assessmentMeta = AssessmentMeta.fromCSV(lines.removeAt(0)); + final assessmentMeta = TrialMeta.fromCSV(lines.removeAt(0)); final deviceMeta = lines.removeAt(0).split(","); final inferredClassification = deviceMeta.removeLast(); final output = PoseDetectionOutput( diff --git a/lib/features/video/view/capture_view.dart b/lib/features/video/view/capture_view.dart index 1e67c081..03d54182 100644 --- a/lib/features/video/view/capture_view.dart +++ b/lib/features/video/view/capture_view.dart @@ -9,7 +9,6 @@ import 'package:visualpt/features/video/view/video_presenter.dart'; import 'package:visualpt/core/views/components/_components.dart'; import 'package:visualpt/features/video/view/video_controls.dart'; -//TODO: Everything within this widget should be rebuilt on the native side class CaptureView extends StatelessWidget { final AssessmentActive state; const CaptureView(this.state, {super.key}); @@ -20,11 +19,14 @@ class CaptureView extends StatelessWidget { const ViewBackground(), VideoPresenter( key: UniqueKey(), - state.meta, - state.assessment.conditions, + state.storeData, + state.trialMeta, + state.meta.conditions, + state.meta.trialDuration, onRecording: showingVideo, onStandby: showingVideo, - onStopped: stoppedVideo, + onPaused: showingVideo, + onEnded: endedVideo, onLoading: loadingVideo, onError: errorVideo, ), @@ -60,14 +62,14 @@ class CaptureView extends StatelessWidget { return const Center(child: AppLoading(message: "Video Stream Loading")); } - Widget stoppedVideo(BuildContext context, VideoStopped state) { + Widget endedVideo(BuildContext context, VideoEnded state) { final vid = BlocProvider.of(context); return Center( child: Column( children: [ const Text("Video Stopped"), CupertinoButton( - onPressed: () => vid.add(const VideoPlay()), + onPressed: () => vid.add(const VideoInit()), child: const Text("Restart"), ), ], diff --git a/lib/features/video/view/video_controls.dart b/lib/features/video/view/video_controls.dart index d6d71af2..b2032d7d 100644 --- a/lib/features/video/view/video_controls.dart +++ b/lib/features/video/view/video_controls.dart @@ -9,7 +9,6 @@ import 'package:visualpt/features/inference/service/pose_detection/pose_detectio import 'package:visualpt/features/inference/service/pose_detection/pose_detection_output.dart'; import 'package:visualpt/features/inference/view/pose_classification_indicator.dart'; import 'package:visualpt/features/inference/view/pose_detection_indicator.dart'; -import 'package:visualpt/features/timer/bloc/timer_bloc.dart'; import 'package:visualpt/features/video/bloc/video_bloc.dart'; import 'package:visualpt/core/views/_views.dart'; import 'package:visualpt/features/video/service/video_detection_repository.dart'; @@ -28,44 +27,80 @@ class VideoControls extends StatelessWidget { classification = BlocProvider.of< InferenceBloc>( context), - timer = BlocProvider.of(context), vid = BlocProvider.of(context), asmt = BlocProvider.of>(context); + final vidDecRep = RepositoryProvider.of(context), + detClsRep = + RepositoryProvider.of(context); + void onComplete(VideoRecording state) { - final index = BlocProvider.of(context) - .currentCondition - ?.sequenceNumber ?? - 0; - final duration = timer.state.duration; + final index = asmt.currentCondition?.sequenceNumber ?? 0; + detection.add(const InferenceEnd("End")); classification.add(const InferenceEnd("End")); vid.add(const VideoStop(reset: false)); - timer.add(const TimerReset()); asmt.add(AssessmentTrial( AssessmentUtils.linkOutput( - RepositoryProvider.of(context) - .localStorage, - RepositoryProvider.of(context) - .localStorage, - { - "index": index, - "obtrustion": BlocProvider.of(context) - .config - .conditionObtrusion[index], - "instability": BlocProvider.of(context) - .config - .conditionInstability[index] - }), + vidDecRep.localStorage, detClsRep.localStorage, { + "index": index, + "obtrustion": asmt.config.conditionObtrusion[index], + "instability": asmt.config.conditionInstability[index] + }), state.startTime, - duration)); + state.duration)); + } + + void onPause(VideoRecording state) { + vid.add(const VideoPause()); + detection.add(const InferenceEnd("End")); + classification.add(const InferenceEnd("End")); + + //Show a dialog to confirm the user's next steps + WidgetsBinding.instance.addPostFrameCallback((_) { + showCupertinoDialog( + context: context, + builder: (context) { + return CupertinoAlertDialog( + title: const Text("Trial Complete"), + content: const Text( + "Would you like to continue with the next trial or review the current trial?"), + actions: [ + CupertinoDialogAction( + child: const Text("Reset"), + onPressed: () { + vid.add(const VideoStop(reset: true)); + detection.add(const InferenceInit("Start", 1)); + classification.add(const InferenceInit("Start", 1)); + Navigator.of(context).pop(); + }, + ), + CupertinoDialogAction( + child: const Text("Next"), + onPressed: () { + onComplete(state); + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + }); } return BlocBuilder, InferenceState>( builder: (context, infState) { - if (infState is InferenceRunning && - infState.response != null) {} + if (state.pauseFlag || + (state.duration >= Duration(seconds: asmt.config.trialDuration) || + (infState is InferenceRunning && + infState.response != null && + asmt.config.isComplete(infState.response!)))) { + //Check if the duration will trigger a completed capture + onPause(state as VideoRecording); + } return LayoutBuilder( builder: (context, constraints) { return Stack( @@ -97,76 +132,57 @@ class VideoControls extends StatelessWidget { bottom: 0, left: 0, right: 0, - child: BlocBuilder( - builder: (context, timerState) { - if (timerState is TimerRunComplete) { - onComplete(state as VideoRecording); - } - return Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - children: [ - const Spacer(), - if (infState is InferenceLoading) ...[ - const CupertinoActivityIndicator( - radius: 20, - ) - ] else if (infState is InferenceStandby && - state is VideoStandby) ...[ - GestureDetector( - onTap: () { - final controller = - (state as VideoStandby).controller; - //TODO: Why is the width equal to the height? This seems to be incorrect but yields the correct result - final params = { - "height": controller.value.previewSize!.width, - "width": controller.value.previewSize!.height, - }; - vid.add(const VideoPlay()); - detection.add(InferenceStart("Start", params)); - classification - .add(InferenceStart("Start", params)); - timer.add(const TimerStarted()); - }, - child: Container( - decoration: const BoxDecoration( - color: Styles.actionSecondary, - shape: BoxShape.circle, - ), - child: const Icon( - CupertinoIcons.circle_fill, - color: Styles.actionPrimary, - size: 100, - ), - ), - ) - ] else ...[ - GestureDetector( - onTap: () { - BlocProvider.of(context) - .add(const VideoStop(reset: true)); - BlocProvider.of(context) - .add(const TimerReset()); - }, - child: const Icon(CupertinoIcons.restart, - color: Styles.white, size: 40), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + const Spacer(), + if (infState is InferenceLoading) ...[ + const CupertinoActivityIndicator( + radius: 20, + ) + ] else if (infState is InferenceStandby && + state is VideoStandby) ...[ + GestureDetector( + onTap: () { + final controller = + (state as VideoStandby).controller; + //TODO: Why is the width equal to the height? This seems to be incorrect but yields the correct result + final params = { + "height": controller.value.previewSize!.width, + "width": controller.value.previewSize!.height, + }; + vid.add(const VideoPlay()); + detection.add(InferenceStart("Start", params)); + classification.add(InferenceStart("Start", params)); + }, + child: Container( + decoration: const BoxDecoration( + color: Styles.actionSecondary, + shape: BoxShape.circle, ), - const Spacer(), - GestureDetector( - onTap: () => onComplete(state as VideoRecording), - child: const Icon(CupertinoIcons.stop, - color: Styles.actionPrimary, size: 100), + child: const Icon( + CupertinoIcons.circle_fill, + color: Styles.actionPrimary, + size: 100, ), - const Spacer(), - Text(formatter(timerState.duration), - style: Styles.timerTextStyle), - ], - const Spacer(), - ], - ); - }, + ), + ) + ] else ...[ + const Spacer(flex: 2), + GestureDetector( + onTap: () => onPause(state as VideoRecording), + child: const Icon(CupertinoIcons.stop, + color: Styles.actionPrimary, size: 100), + ), + const Spacer(), + Text(formatter(state.duration), + style: Styles.timerTextStyle), + ], + const Spacer(), + ], ), ), ], diff --git a/lib/features/video/view/video_presenter.dart b/lib/features/video/view/video_presenter.dart index 0ec059e8..e47d39b6 100644 --- a/lib/features/video/view/video_presenter.dart +++ b/lib/features/video/view/video_presenter.dart @@ -3,7 +3,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:visualpt/features/assessment/types.dart'; import 'package:visualpt/features/video/types.dart'; import 'package:visualpt/features/auth/bloc/auth_bloc.dart'; -import 'package:visualpt/features/timer/bloc/timer_bloc.dart'; import 'package:visualpt/features/video/bloc/video_bloc.dart'; import 'package:visualpt/features/inference/bloc/inference_bloc.dart'; import 'package:visualpt/features/inference/service/pose_classification/pose_class_output.dart'; @@ -14,21 +13,27 @@ import 'package:visualpt/features/inference/service/pose_detection/pose_detectio ///Provided by VideoBloc, TimerBloc, & InferenceBloc class VideoPresenter extends StatelessWidget { - final AssessmentMeta meta; + final bool storeData; + final TrialMeta meta; final int address; + final int durationSeconds; final Widget Function(BuildContext context, VideoRecording state) onRecording; final Widget Function(BuildContext context, VideoStandby state) onStandby; - final Widget Function(BuildContext context, VideoStopped state) onStopped; + final Widget Function(BuildContext context, VideoPaused state) onPaused; + final Widget Function(BuildContext context, VideoEnded state) onEnded; final Widget Function(BuildContext context, VideoLoading state) onLoading; final Widget Function(BuildContext context, VideoState state, Exception e) onError; const VideoPresenter( + this.storeData, this.meta, - this.address, { + this.address, + this.durationSeconds, { super.key, required this.onRecording, required this.onStandby, - required this.onStopped, + required this.onPaused, + required this.onEnded, required this.onLoading, required this.onError, }); @@ -39,10 +44,11 @@ class VideoPresenter extends StatelessWidget { providers: [ RepositoryProvider( create: (context) => VideoDetectionRepository( - meta, - (BlocProvider.of(context).state as AuthSuccess) - .user - .email), + meta, + (BlocProvider.of(context).state as AuthSuccess) + .user + .email, + ), ), RepositoryProvider( create: (context) => DetectionClassificationRepository(meta), @@ -52,12 +58,10 @@ class VideoPresenter extends StatelessWidget { providers: [ BlocProvider( create: (context) => VideoBloc( - RepositoryProvider.of(context)) + RepositoryProvider.of(context), + Duration(seconds: durationSeconds)) ..add(const VideoInit()), ), - BlocProvider( - create: (context) => TimerBloc(), - ), BlocProvider( lazy: false, create: (context) { @@ -65,7 +69,8 @@ class VideoPresenter extends StatelessWidget { RepositoryProvider.of(context), RepositoryProvider.of( context), - VPTDetectionRate) + VPTDetectionRate, + storeData) ..add(InferenceInit("init", address)); }, ), @@ -77,7 +82,8 @@ class VideoPresenter extends StatelessWidget { RepositoryProvider.of( context), null, - VPTClassificationRate) + VPTClassificationRate, + storeData) ..add(InferenceInit("init", address)); }, ) @@ -89,8 +95,10 @@ class VideoPresenter extends StatelessWidget { return onStandby(context, state); } else if (state is VideoRecording) { return onRecording(context, state); - } else if (state is VideoStopped) { - return onStopped(context, state); + } else if (state is VideoPaused) { + return onPaused(context, state); + } else if (state is VideoEnded) { + return onEnded(context, state); } else if (state is VideoLoading) { return onLoading(context, state); } else { diff --git a/lib/main.dart b/lib/main.dart index 6aed24e3..a287503c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -70,7 +70,11 @@ class VisualPT extends StatelessWidget { return CupertinoPageRoute( builder: (context) => ValidationView(type)); } - return null; + return CupertinoPageRoute( + builder: (context) => + //TODO: Add a 404 page + const HomeView(), + ); }, ), ), diff --git a/lib/models/AgeRange.dart b/lib/models/AgeRange.dart new file mode 100644 index 00000000..8471feb8 --- /dev/null +++ b/lib/models/AgeRange.dart @@ -0,0 +1,32 @@ +/* +* Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +* +* Licensed under the Apache License, Version 2.0 (the "License"). +* You may not use this file except in compliance with the License. +* A copy of the License is located at +* +* http://aws.amazon.com/apache2.0 +* +* or in the "license" file accompanying this file. This file is distributed +* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +* express or implied. See the License for the specific language governing +* permissions and limitations under the License. +*/ + +// NOTE: This file is generated and may not follow lint rules defined in your app +// Generated files can be excluded from analysis in analysis_options.yaml +// For more info, see: https://dart.dev/guides/language/analysis-options#excluding-code-from-analysis + +// ignore_for_file: public_member_api_docs, annotate_overrides, dead_code, dead_codepublic_member_api_docs, depend_on_referenced_packages, file_names, library_private_types_in_public_api, no_leading_underscores_for_library_prefixes, no_leading_underscores_for_local_identifiers, non_constant_identifier_names, null_check_on_nullable_type_parameter, override_on_non_overriding_member, prefer_adjacent_string_concatenation, prefer_const_constructors, prefer_if_null_operators, prefer_interpolation_to_compose_strings, slash_for_doc_comments, sort_child_properties_last, unnecessary_const, unnecessary_constructor_name, unnecessary_late, unnecessary_new, unnecessary_null_aware_assignments, unnecessary_nullable_for_final_variable_declarations, unnecessary_string_interpolations, use_build_context_synchronously + +enum AgeRange { + BABY_0_5, + CHILD_6_12, + TEEN_13_19, + ADULT_20_39, + ADULT_40_64, + SENIOR_65_69, + SENIOR_70_79, + SENIOR_80_84, + SENIOR_85_89 +} \ No newline at end of file diff --git a/lib/models/Assessment.dart b/lib/models/Assessment.dart index 6d14f815..b6f2510d 100644 --- a/lib/models/Assessment.dart +++ b/lib/models/Assessment.dart @@ -253,6 +253,17 @@ class Assessment extends amplify_core.Model { modelSchemaDefinition.pluralName = "Assessments"; modelSchemaDefinition.authRules = [ + amplify_core.AuthRule( + authStrategy: amplify_core.AuthStrategy.GROUPS, + groupClaim: "cognito:groups", + groups: [ "Admin" ], + provider: amplify_core.AuthRuleProvider.USERPOOLS, + operations: const [ + amplify_core.ModelOperation.CREATE, + amplify_core.ModelOperation.UPDATE, + amplify_core.ModelOperation.DELETE, + amplify_core.ModelOperation.READ + ]), amplify_core.AuthRule( authStrategy: amplify_core.AuthStrategy.OWNER, ownerField: "owner", diff --git a/lib/models/AssessmentType.dart b/lib/models/AssessmentType.dart index 21855b00..061b91c6 100644 --- a/lib/models/AssessmentType.dart +++ b/lib/models/AssessmentType.dart @@ -19,4 +19,12 @@ // ignore_for_file: public_member_api_docs, annotate_overrides, dead_code, dead_codepublic_member_api_docs, depend_on_referenced_packages, file_names, library_private_types_in_public_api, no_leading_underscores_for_library_prefixes, no_leading_underscores_for_local_identifiers, non_constant_identifier_names, null_check_on_nullable_type_parameter, override_on_non_overriding_member, prefer_adjacent_string_concatenation, prefer_const_constructors, prefer_if_null_operators, prefer_interpolation_to_compose_strings, slash_for_doc_comments, sort_child_properties_last, unnecessary_const, unnecessary_constructor_name, unnecessary_late, unnecessary_new, unnecessary_null_aware_assignments, unnecessary_nullable_for_final_variable_declarations, unnecessary_string_interpolations, use_build_context_synchronously -enum AssessmentType { CTSIB, MCTSIB, CTSIB_COLLECT, GAIT, BERG, REACH, FIST } +enum AssessmentType { + CTSIB, + MCTSIB, + GAIT, + BERG, + REACH, + FIST, + DEV +} \ No newline at end of file diff --git a/lib/models/Condition.dart b/lib/models/Condition.dart index d9a2e39c..a0121c8c 100644 --- a/lib/models/Condition.dart +++ b/lib/models/Condition.dart @@ -216,6 +216,17 @@ class Condition extends amplify_core.Model { modelSchemaDefinition.pluralName = "Conditions"; modelSchemaDefinition.authRules = [ + amplify_core.AuthRule( + authStrategy: amplify_core.AuthStrategy.GROUPS, + groupClaim: "cognito:groups", + groups: [ "Admin" ], + provider: amplify_core.AuthRuleProvider.USERPOOLS, + operations: const [ + amplify_core.ModelOperation.CREATE, + amplify_core.ModelOperation.UPDATE, + amplify_core.ModelOperation.DELETE, + amplify_core.ModelOperation.READ + ]), amplify_core.AuthRule( authStrategy: amplify_core.AuthStrategy.OWNER, ownerField: "owner", diff --git a/lib/models/ModelProvider.dart b/lib/models/ModelProvider.dart index 61ae2276..1a366bbc 100644 --- a/lib/models/ModelProvider.dart +++ b/lib/models/ModelProvider.dart @@ -22,12 +22,14 @@ import 'package:amplify_core/amplify_core.dart' as amplify_core; import 'Assessment.dart'; import 'Condition.dart'; +import 'Norm.dart'; import 'Subject.dart'; import 'Trial.dart'; import 'User.dart'; import 'Detection.dart'; import 'DetectionEnv.dart'; +export 'AgeRange.dart'; export 'Assessment.dart'; export 'AssessmentType.dart'; export 'Condition.dart'; @@ -35,6 +37,7 @@ export 'Detection.dart'; export 'DetectionEnv.dart'; export 'FallHistory.dart'; export 'Gender.dart'; +export 'Norm.dart'; export 'Subject.dart'; export 'Trial.dart'; export 'User.dart'; @@ -42,9 +45,9 @@ export 'UserStatus.dart'; class ModelProvider implements amplify_core.ModelProviderInterface { @override - String version = "ce263c873b35f1bb4af8b157836700a5"; + String version = "3bbfb3ae7bb611fb695a686ecab87a7a"; @override - List modelSchemas = [Assessment.schema, Condition.schema, Subject.schema, Trial.schema, User.schema]; + List modelSchemas = [Assessment.schema, Condition.schema, Norm.schema, Subject.schema, Trial.schema, User.schema]; @override List customTypeSchemas = [Detection.schema, DetectionEnv.schema]; static final ModelProvider _instance = ModelProvider(); @@ -57,6 +60,8 @@ class ModelProvider implements amplify_core.ModelProviderInterface { return Assessment.classType; case "Condition": return Condition.classType; + case "Norm": + return Norm.classType; case "Subject": return Subject.classType; case "Trial": diff --git a/lib/models/Norm.dart b/lib/models/Norm.dart new file mode 100644 index 00000000..e42869d4 --- /dev/null +++ b/lib/models/Norm.dart @@ -0,0 +1,379 @@ +/* +* Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +* +* Licensed under the Apache License, Version 2.0 (the "License"). +* You may not use this file except in compliance with the License. +* A copy of the License is located at +* +* http://aws.amazon.com/apache2.0 +* +* or in the "license" file accompanying this file. This file is distributed +* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +* express or implied. See the License for the specific language governing +* permissions and limitations under the License. +*/ + +// NOTE: This file is generated and may not follow lint rules defined in your app +// Generated files can be excluded from analysis in analysis_options.yaml +// For more info, see: https://dart.dev/guides/language/analysis-options#excluding-code-from-analysis + +// ignore_for_file: public_member_api_docs, annotate_overrides, dead_code, dead_codepublic_member_api_docs, depend_on_referenced_packages, file_names, library_private_types_in_public_api, no_leading_underscores_for_library_prefixes, no_leading_underscores_for_local_identifiers, non_constant_identifier_names, null_check_on_nullable_type_parameter, override_on_non_overriding_member, prefer_adjacent_string_concatenation, prefer_const_constructors, prefer_if_null_operators, prefer_interpolation_to_compose_strings, slash_for_doc_comments, sort_child_properties_last, unnecessary_const, unnecessary_constructor_name, unnecessary_late, unnecessary_new, unnecessary_null_aware_assignments, unnecessary_nullable_for_final_variable_declarations, unnecessary_string_interpolations, use_build_context_synchronously + +import 'ModelProvider.dart'; +import 'package:amplify_core/amplify_core.dart' as amplify_core; + + +/** This is an auto generated class representing the Norm type in your schema. */ +class Norm extends amplify_core.Model { + static const classType = const _NormModelType(); + final String id; + final AssessmentType? _type; + final int? _sequenceNumber; + final AgeRange? _age_range; + final Gender? _gender; + final double? _score_sum; + final int? _trial_sum; + final amplify_core.TemporalDateTime? _createdAt; + final amplify_core.TemporalDateTime? _updatedAt; + + @override + getInstanceType() => classType; + + @Deprecated('[getId] is being deprecated in favor of custom primary key feature. Use getter [modelIdentifier] to get model identifier.') + @override + String getId() => id; + + NormModelIdentifier get modelIdentifier { + return NormModelIdentifier( + id: id + ); + } + + AssessmentType get type { + try { + return _type!; + } catch(e) { + throw amplify_core.AmplifyCodeGenModelException( + amplify_core.AmplifyExceptionMessages.codeGenRequiredFieldForceCastExceptionMessage, + recoverySuggestion: + amplify_core.AmplifyExceptionMessages.codeGenRequiredFieldForceCastRecoverySuggestion, + underlyingException: e.toString() + ); + } + } + + int get sequenceNumber { + try { + return _sequenceNumber!; + } catch(e) { + throw amplify_core.AmplifyCodeGenModelException( + amplify_core.AmplifyExceptionMessages.codeGenRequiredFieldForceCastExceptionMessage, + recoverySuggestion: + amplify_core.AmplifyExceptionMessages.codeGenRequiredFieldForceCastRecoverySuggestion, + underlyingException: e.toString() + ); + } + } + + AgeRange get age_range { + try { + return _age_range!; + } catch(e) { + throw amplify_core.AmplifyCodeGenModelException( + amplify_core.AmplifyExceptionMessages.codeGenRequiredFieldForceCastExceptionMessage, + recoverySuggestion: + amplify_core.AmplifyExceptionMessages.codeGenRequiredFieldForceCastRecoverySuggestion, + underlyingException: e.toString() + ); + } + } + + Gender get gender { + try { + return _gender!; + } catch(e) { + throw amplify_core.AmplifyCodeGenModelException( + amplify_core.AmplifyExceptionMessages.codeGenRequiredFieldForceCastExceptionMessage, + recoverySuggestion: + amplify_core.AmplifyExceptionMessages.codeGenRequiredFieldForceCastRecoverySuggestion, + underlyingException: e.toString() + ); + } + } + + double get score_sum { + try { + return _score_sum!; + } catch(e) { + throw amplify_core.AmplifyCodeGenModelException( + amplify_core.AmplifyExceptionMessages.codeGenRequiredFieldForceCastExceptionMessage, + recoverySuggestion: + amplify_core.AmplifyExceptionMessages.codeGenRequiredFieldForceCastRecoverySuggestion, + underlyingException: e.toString() + ); + } + } + + int get trial_sum { + try { + return _trial_sum!; + } catch(e) { + throw amplify_core.AmplifyCodeGenModelException( + amplify_core.AmplifyExceptionMessages.codeGenRequiredFieldForceCastExceptionMessage, + recoverySuggestion: + amplify_core.AmplifyExceptionMessages.codeGenRequiredFieldForceCastRecoverySuggestion, + underlyingException: e.toString() + ); + } + } + + amplify_core.TemporalDateTime? get createdAt { + return _createdAt; + } + + amplify_core.TemporalDateTime? get updatedAt { + return _updatedAt; + } + + const Norm._internal({required this.id, required type, required sequenceNumber, required age_range, required gender, required score_sum, required trial_sum, createdAt, updatedAt}): _type = type, _sequenceNumber = sequenceNumber, _age_range = age_range, _gender = gender, _score_sum = score_sum, _trial_sum = trial_sum, _createdAt = createdAt, _updatedAt = updatedAt; + + factory Norm({String? id, required AssessmentType type, required int sequenceNumber, required AgeRange age_range, required Gender gender, required double score_sum, required int trial_sum}) { + return Norm._internal( + id: id == null ? amplify_core.UUID.getUUID() : id, + type: type, + sequenceNumber: sequenceNumber, + age_range: age_range, + gender: gender, + score_sum: score_sum, + trial_sum: trial_sum); + } + + bool equals(Object other) { + return this == other; + } + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is Norm && + id == other.id && + _type == other._type && + _sequenceNumber == other._sequenceNumber && + _age_range == other._age_range && + _gender == other._gender && + _score_sum == other._score_sum && + _trial_sum == other._trial_sum; + } + + @override + int get hashCode => toString().hashCode; + + @override + String toString() { + var buffer = new StringBuffer(); + + buffer.write("Norm {"); + buffer.write("id=" + "$id" + ", "); + buffer.write("type=" + (_type != null ? amplify_core.enumToString(_type)! : "null") + ", "); + buffer.write("sequenceNumber=" + (_sequenceNumber != null ? _sequenceNumber!.toString() : "null") + ", "); + buffer.write("age_range=" + (_age_range != null ? amplify_core.enumToString(_age_range)! : "null") + ", "); + buffer.write("gender=" + (_gender != null ? amplify_core.enumToString(_gender)! : "null") + ", "); + buffer.write("score_sum=" + (_score_sum != null ? _score_sum!.toString() : "null") + ", "); + buffer.write("trial_sum=" + (_trial_sum != null ? _trial_sum!.toString() : "null") + ", "); + buffer.write("createdAt=" + (_createdAt != null ? _createdAt!.format() : "null") + ", "); + buffer.write("updatedAt=" + (_updatedAt != null ? _updatedAt!.format() : "null")); + buffer.write("}"); + + return buffer.toString(); + } + + Norm copyWith({AssessmentType? type, int? sequenceNumber, AgeRange? age_range, Gender? gender, double? score_sum, int? trial_sum}) { + return Norm._internal( + id: id, + type: type ?? this.type, + sequenceNumber: sequenceNumber ?? this.sequenceNumber, + age_range: age_range ?? this.age_range, + gender: gender ?? this.gender, + score_sum: score_sum ?? this.score_sum, + trial_sum: trial_sum ?? this.trial_sum); + } + + Norm copyWithModelFieldValues({ + ModelFieldValue? type, + ModelFieldValue? sequenceNumber, + ModelFieldValue? age_range, + ModelFieldValue? gender, + ModelFieldValue? score_sum, + ModelFieldValue? trial_sum + }) { + return Norm._internal( + id: id, + type: type == null ? this.type : type.value, + sequenceNumber: sequenceNumber == null ? this.sequenceNumber : sequenceNumber.value, + age_range: age_range == null ? this.age_range : age_range.value, + gender: gender == null ? this.gender : gender.value, + score_sum: score_sum == null ? this.score_sum : score_sum.value, + trial_sum: trial_sum == null ? this.trial_sum : trial_sum.value + ); + } + + Norm.fromJson(Map json) + : id = json['id'], + _type = amplify_core.enumFromString(json['type'], AssessmentType.values), + _sequenceNumber = (json['sequenceNumber'] as num?)?.toInt(), + _age_range = amplify_core.enumFromString(json['age_range'], AgeRange.values), + _gender = amplify_core.enumFromString(json['gender'], Gender.values), + _score_sum = (json['score_sum'] as num?)?.toDouble(), + _trial_sum = (json['trial_sum'] as num?)?.toInt(), + _createdAt = json['createdAt'] != null ? amplify_core.TemporalDateTime.fromString(json['createdAt']) : null, + _updatedAt = json['updatedAt'] != null ? amplify_core.TemporalDateTime.fromString(json['updatedAt']) : null; + + Map toJson() => { + 'id': id, 'type': amplify_core.enumToString(_type), 'sequenceNumber': _sequenceNumber, 'age_range': amplify_core.enumToString(_age_range), 'gender': amplify_core.enumToString(_gender), 'score_sum': _score_sum, 'trial_sum': _trial_sum, 'createdAt': _createdAt?.format(), 'updatedAt': _updatedAt?.format() + }; + + Map toMap() => { + 'id': id, + 'type': _type, + 'sequenceNumber': _sequenceNumber, + 'age_range': _age_range, + 'gender': _gender, + 'score_sum': _score_sum, + 'trial_sum': _trial_sum, + 'createdAt': _createdAt, + 'updatedAt': _updatedAt + }; + + static final amplify_core.QueryModelIdentifier MODEL_IDENTIFIER = amplify_core.QueryModelIdentifier(); + static final ID = amplify_core.QueryField(fieldName: "id"); + static final TYPE = amplify_core.QueryField(fieldName: "type"); + static final SEQUENCENUMBER = amplify_core.QueryField(fieldName: "sequenceNumber"); + static final AGE_RANGE = amplify_core.QueryField(fieldName: "age_range"); + static final GENDER = amplify_core.QueryField(fieldName: "gender"); + static final SCORE_SUM = amplify_core.QueryField(fieldName: "score_sum"); + static final TRIAL_SUM = amplify_core.QueryField(fieldName: "trial_sum"); + static var schema = amplify_core.Model.defineSchema(define: (amplify_core.ModelSchemaDefinition modelSchemaDefinition) { + modelSchemaDefinition.name = "Norm"; + modelSchemaDefinition.pluralName = "Norms"; + + modelSchemaDefinition.authRules = [ + amplify_core.AuthRule( + authStrategy: amplify_core.AuthStrategy.PRIVATE, + operations: const [ + amplify_core.ModelOperation.CREATE, + amplify_core.ModelOperation.UPDATE, + amplify_core.ModelOperation.DELETE, + amplify_core.ModelOperation.READ + ]) + ]; + + modelSchemaDefinition.addField(amplify_core.ModelFieldDefinition.id()); + + modelSchemaDefinition.addField(amplify_core.ModelFieldDefinition.field( + key: Norm.TYPE, + isRequired: true, + ofType: amplify_core.ModelFieldType(amplify_core.ModelFieldTypeEnum.enumeration) + )); + + modelSchemaDefinition.addField(amplify_core.ModelFieldDefinition.field( + key: Norm.SEQUENCENUMBER, + isRequired: true, + ofType: amplify_core.ModelFieldType(amplify_core.ModelFieldTypeEnum.int) + )); + + modelSchemaDefinition.addField(amplify_core.ModelFieldDefinition.field( + key: Norm.AGE_RANGE, + isRequired: true, + ofType: amplify_core.ModelFieldType(amplify_core.ModelFieldTypeEnum.enumeration) + )); + + modelSchemaDefinition.addField(amplify_core.ModelFieldDefinition.field( + key: Norm.GENDER, + isRequired: true, + ofType: amplify_core.ModelFieldType(amplify_core.ModelFieldTypeEnum.enumeration) + )); + + modelSchemaDefinition.addField(amplify_core.ModelFieldDefinition.field( + key: Norm.SCORE_SUM, + isRequired: true, + ofType: amplify_core.ModelFieldType(amplify_core.ModelFieldTypeEnum.double) + )); + + modelSchemaDefinition.addField(amplify_core.ModelFieldDefinition.field( + key: Norm.TRIAL_SUM, + isRequired: true, + ofType: amplify_core.ModelFieldType(amplify_core.ModelFieldTypeEnum.int) + )); + + modelSchemaDefinition.addField(amplify_core.ModelFieldDefinition.nonQueryField( + fieldName: 'createdAt', + isRequired: false, + isReadOnly: true, + ofType: amplify_core.ModelFieldType(amplify_core.ModelFieldTypeEnum.dateTime) + )); + + modelSchemaDefinition.addField(amplify_core.ModelFieldDefinition.nonQueryField( + fieldName: 'updatedAt', + isRequired: false, + isReadOnly: true, + ofType: amplify_core.ModelFieldType(amplify_core.ModelFieldTypeEnum.dateTime) + )); + }); +} + +class _NormModelType extends amplify_core.ModelType { + const _NormModelType(); + + @override + Norm fromJson(Map jsonData) { + return Norm.fromJson(jsonData); + } + + @override + String modelName() { + return 'Norm'; + } +} + +/** + * This is an auto generated class representing the model identifier + * of [Norm] in your schema. + */ +class NormModelIdentifier implements amplify_core.ModelIdentifier { + final String id; + + /** Create an instance of NormModelIdentifier using [id] the primary key. */ + const NormModelIdentifier({ + required this.id}); + + @override + Map serializeAsMap() => ({ + 'id': id + }); + + @override + List> serializeAsList() => serializeAsMap() + .entries + .map((entry) => ({ entry.key: entry.value })) + .toList(); + + @override + String serializeAsString() => serializeAsMap().values.join('#'); + + @override + String toString() => 'NormModelIdentifier(id: $id)'; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + return other is NormModelIdentifier && + id == other.id; + } + + @override + int get hashCode => + id.hashCode; +} \ No newline at end of file diff --git a/lib/models/Subject.dart b/lib/models/Subject.dart index 8f6703b7..60211f9d 100644 --- a/lib/models/Subject.dart +++ b/lib/models/Subject.dart @@ -331,6 +331,17 @@ class Subject extends amplify_core.Model { modelSchemaDefinition.pluralName = "Subjects"; modelSchemaDefinition.authRules = [ + amplify_core.AuthRule( + authStrategy: amplify_core.AuthStrategy.GROUPS, + groupClaim: "cognito:groups", + groups: [ "Admin" ], + provider: amplify_core.AuthRuleProvider.USERPOOLS, + operations: const [ + amplify_core.ModelOperation.CREATE, + amplify_core.ModelOperation.UPDATE, + amplify_core.ModelOperation.DELETE, + amplify_core.ModelOperation.READ + ]), amplify_core.AuthRule( authStrategy: amplify_core.AuthStrategy.OWNER, ownerField: "owner", diff --git a/lib/models/Trial.dart b/lib/models/Trial.dart index c5765b62..02f6764f 100644 --- a/lib/models/Trial.dart +++ b/lib/models/Trial.dart @@ -208,6 +208,17 @@ class Trial extends amplify_core.Model { modelSchemaDefinition.pluralName = "Trials"; modelSchemaDefinition.authRules = [ + amplify_core.AuthRule( + authStrategy: amplify_core.AuthStrategy.GROUPS, + groupClaim: "cognito:groups", + groups: [ "Admin" ], + provider: amplify_core.AuthRuleProvider.USERPOOLS, + operations: const [ + amplify_core.ModelOperation.CREATE, + amplify_core.ModelOperation.UPDATE, + amplify_core.ModelOperation.DELETE, + amplify_core.ModelOperation.READ + ]), amplify_core.AuthRule( authStrategy: amplify_core.AuthStrategy.OWNER, ownerField: "owner", diff --git a/lib/models/User.dart b/lib/models/User.dart index 13af076d..6cde34a6 100644 --- a/lib/models/User.dart +++ b/lib/models/User.dart @@ -240,7 +240,21 @@ class User extends amplify_core.Model { modelSchemaDefinition.authRules = [ amplify_core.AuthRule( - authStrategy: amplify_core.AuthStrategy.PUBLIC, + authStrategy: amplify_core.AuthStrategy.GROUPS, + groupClaim: "cognito:groups", + groups: [ "Admin" ], + provider: amplify_core.AuthRuleProvider.USERPOOLS, + operations: const [ + amplify_core.ModelOperation.CREATE, + amplify_core.ModelOperation.UPDATE, + amplify_core.ModelOperation.DELETE, + amplify_core.ModelOperation.READ + ]), + amplify_core.AuthRule( + authStrategy: amplify_core.AuthStrategy.OWNER, + ownerField: "owner", + identityClaim: "cognito:username", + provider: amplify_core.AuthRuleProvider.USERPOOLS, operations: const [ amplify_core.ModelOperation.CREATE, amplify_core.ModelOperation.UPDATE, diff --git a/pubspec.lock b/pubspec.lock index 6d5e5111..2cfb3c3a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -506,10 +506,10 @@ packages: dependency: "direct main" description: name: flutter_bloc - sha256: f0ecf6e6eb955193ca60af2d5ca39565a86b8a142452c5b24d96fb477428f4d2 + sha256: b594505eac31a0518bdcb4b5b79573b8d9117b193cc80cc12e17d639b10aa27a url: "https://pub.dev" source: hosted - version: "8.1.5" + version: "8.1.6" flutter_lints: dependency: "direct dev" description: @@ -844,10 +844,10 @@ packages: dependency: "direct main" description: name: pdf - sha256: "243f05342fc0bdf140eba5b069398985cdbdd3dbb1d776cf43d5ea29cc570ba6" + sha256: "05df53f8791587402493ac97b9869d3824eccbc77d97855f4545cf72df3cae07" url: "https://pub.dev" source: hosted - version: "3.10.8" + version: "3.11.1" pdf_widget_wrapper: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 2ac31d6a..778178fd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,7 +13,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.1.1+1 +version: 1.1.4+1 environment: sdk: ">=3.2.0 <4.0.0" @@ -41,7 +41,7 @@ dependencies: cupertino_icons: ^1.0.5 equatable: ^2.0.5 faker: ^2.1.0 - flutter_bloc: ^8.1.4 + flutter_bloc: ^8.1.6 flutter_pdfview: ^1.3.2 google_fonts: ^4.0.4 get_it: ^7.6.7 @@ -49,7 +49,7 @@ dependencies: image: ^4.1.6 intl: ^0.19.0 path_provider: ^2.1.2 - pdf: ^3.10.8 + pdf: ^3.11.1 pdf_widget_wrapper: ^1.0.4 # This is for a build error on 1.0.3, when the dependent package has an available update, this can be removed printing: ^5.12.0 scidart: ^0.0.2-dev.12