inou/app/lib/features/dashboard/dossier_page.dart

811 lines
24 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:inou_app/design/inou_theme.dart';
import 'package:inou_app/design/inou_text.dart';
import 'package:inou_app/design/widgets/widgets.dart';
import 'models.dart';
import 'mock_data.dart';
class DossierPage extends StatelessWidget {
final String dossierId;
const DossierPage({super.key, required this.dossierId});
@override
Widget build(BuildContext context) {
final data = getDossierById(dossierId);
if (data == null) {
return InouAuthPage(
userName: 'Johan',
onProfileTap: () => context.go('/profile'),
onLogoutTap: () => context.go('/login'),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Dossier not found', style: InouText.sectionTitle),
const SizedBox(height: 16),
InouButton(
text: '← Back to dossiers',
variant: ButtonVariant.secondary,
onPressed: () => context.go('/dashboard'),
),
],
),
),
);
}
return InouAuthPage(
userName: 'Johan',
onProfileTap: () => context.go('/profile'),
onLogoutTap: () => context.go('/login'),
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(
horizontal: InouTheme.spaceXl,
vertical: InouTheme.spaceXxxl,
),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: InouTheme.maxWidthNarrow), // 800px per styleguide
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
_DossierHeader(data: data),
const SizedBox(height: 32),
// Data sections
_ImagingSection(studies: data.studies, dossierId: dossierId),
_LabsSection(labs: data.labs),
if (data.documents.isNotEmpty) _DocumentsSection(documents: data.documents),
if (data.procedures.isNotEmpty) _ProceduresSection(procedures: data.procedures),
if (data.assessments.isNotEmpty) _AssessmentsSection(assessments: data.assessments),
if (data.hasGenome) _GeneticsSection(categories: data.geneticCategories),
_UploadsSection(count: data.uploadCount, size: data.uploadSize, canEdit: data.canEdit),
if (data.medications.isNotEmpty) _MedicationsSection(medications: data.medications),
if (data.symptoms.isNotEmpty) _SymptomsSection(symptoms: data.symptoms),
if (data.hospitalizations.isNotEmpty) _HospitalizationsSection(hospitalizations: data.hospitalizations),
if (data.therapies.isNotEmpty) _TherapiesSection(therapies: data.therapies),
_VitalsSection(), // Coming soon
_PrivacySection(accessList: data.accessList, dossierId: dossierId, canManageAccess: data.canManageAccess),
const SizedBox(height: 48),
const InouFooter(),
],
),
),
),
),
);
}
}
class _DossierHeader extends StatelessWidget {
final DossierData data;
const _DossierHeader({required this.data});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(data.dossier.name, style: InouText.pageTitle),
const SizedBox(height: 8),
Text(
[
if (data.dossier.dateOfBirth != null) 'Born: ${data.dossier.dateOfBirth}',
if (data.dossier.sex != null) data.dossier.sex,
].join(' · '),
style: InouText.intro,
),
],
),
),
InouButton(
text: '← Back to dossiers',
variant: ButtonVariant.secondary,
size: ButtonSize.small,
onPressed: () => context.go('/dashboard'),
),
],
);
}
}
// ============================================
// DATA SECTION CARDS
// ============================================
class _ImagingSection extends StatelessWidget {
final List<ImagingStudy> studies;
final String dossierId;
const _ImagingSection({required this.studies, required this.dossierId});
@override
Widget build(BuildContext context) {
final totalSlices = studies.fold<int>(0, (sum, s) => sum + s.series.fold<int>(0, (ss, ser) => ss + ser.sliceCount));
return _DataCard(
title: 'IMAGING',
indicatorColor: InouTheme.indicatorImaging,
summary: studies.isEmpty
? 'No imaging data'
: '${studies.length} studies, $totalSlices slices',
trailing: studies.isNotEmpty
? InouButton(
text: 'Open viewer',
size: ButtonSize.small,
onPressed: () {
// TODO: open viewer
},
)
: null,
child: studies.isEmpty
? null
: Column(
children: [
for (var i = 0; i < studies.length && i < 5; i++)
_ImagingStudyRow(study: studies[i], dossierId: dossierId),
if (studies.length > 5)
_ShowMoreRow(
text: 'Show all ${studies.length} studies',
onTap: () {
// TODO: expand
},
),
],
),
);
}
}
class _ImagingStudyRow extends StatefulWidget {
final ImagingStudy study;
final String dossierId;
const _ImagingStudyRow({required this.study, required this.dossierId});
@override
State<_ImagingStudyRow> createState() => _ImagingStudyRowState();
}
class _ImagingStudyRowState extends State<_ImagingStudyRow> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final hasSeries = widget.study.seriesCount > 1;
return Column(
children: [
InkWell(
onTap: hasSeries ? () => setState(() => _expanded = !_expanded) : null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
if (hasSeries)
SizedBox(
width: 20,
child: Text(
_expanded ? '' : '+',
style: InouText.mono.copyWith(color: InouTheme.textMuted),
),
)
else
const SizedBox(width: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
widget.study.description,
style: InouText.body.copyWith(fontWeight: FontWeight.w500),
),
),
if (hasSeries)
Text(
'${widget.study.seriesCount} series',
style: InouText.mono,
),
const SizedBox(width: 16),
Text(
_formatDate(widget.study.date),
style: InouText.mono.copyWith(color: InouTheme.textMuted),
),
const SizedBox(width: 8),
Icon(Icons.arrow_forward, size: 16, color: InouTheme.accent),
],
),
),
),
if (_expanded)
Container(
color: InouTheme.bg,
child: Column(
children: [
for (final series in widget.study.series)
if (series.sliceCount > 0)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
const SizedBox(width: 32),
Expanded(
child: Text(
series.description ?? series.modality,
style: InouText.bodySmall,
),
),
Text(
'${series.sliceCount} ${series.sliceCount == 1 ? 'slice' : 'slices'}',
style: InouText.mono,
),
const SizedBox(width: 8),
Icon(Icons.arrow_forward, size: 14, color: InouTheme.accent),
],
),
),
],
),
),
const Divider(height: 1),
],
);
}
String _formatDate(String yyyymmdd) {
if (yyyymmdd.length != 8) return yyyymmdd;
return '${yyyymmdd.substring(4, 6)}/${yyyymmdd.substring(6, 8)}/${yyyymmdd.substring(0, 4)}';
}
}
class _LabsSection extends StatelessWidget {
final List<DataItem> labs;
const _LabsSection({required this.labs});
@override
Widget build(BuildContext context) {
return _DataCard(
title: 'LABS',
indicatorColor: InouTheme.indicatorLabs,
summary: labs.isEmpty ? 'No lab data' : '${labs.length} results',
child: labs.isEmpty
? null
: Column(
children: [
for (final lab in labs) _DataRow(item: lab),
],
),
);
}
}
class _DocumentsSection extends StatelessWidget {
final List<DataItem> documents;
const _DocumentsSection({required this.documents});
@override
Widget build(BuildContext context) {
return _DataCard(
title: 'RECORDS',
indicatorColor: InouTheme.indicatorRecords,
summary: '${documents.length} documents',
child: Column(
children: [
for (final doc in documents) _DataRow(item: doc, showType: true),
],
),
);
}
}
class _ProceduresSection extends StatelessWidget {
final List<DataItem> procedures;
const _ProceduresSection({required this.procedures});
@override
Widget build(BuildContext context) {
return _DataCard(
title: 'PROCEDURES & SURGERY',
indicatorColor: InouTheme.danger,
summary: '${procedures.length} procedures',
child: Column(
children: [
for (final proc in procedures) _DataRow(item: proc),
],
),
);
}
}
class _AssessmentsSection extends StatelessWidget {
final List<DataItem> assessments;
const _AssessmentsSection({required this.assessments});
@override
Widget build(BuildContext context) {
return _DataCard(
title: 'CLINICAL ASSESSMENTS',
indicatorColor: const Color(0xFF7C3AED),
summary: '${assessments.length} assessments',
child: Column(
children: [
for (final assessment in assessments) _DataRow(item: assessment),
],
),
);
}
}
class _GeneticsSection extends StatelessWidget {
final List<GeneticCategory> categories;
const _GeneticsSection({required this.categories});
@override
Widget build(BuildContext context) {
final totalShown = categories.fold<int>(0, (sum, c) => sum + c.shown);
final totalHidden = categories.fold<int>(0, (sum, c) => sum + c.hidden);
return _DataCard(
title: 'GENETICS',
indicatorColor: InouTheme.indicatorGenetics,
summary: '$totalShown variants${totalHidden > 0 ? ' ($totalHidden hidden)' : ''}',
trailing: totalHidden > 0
? InouButton(
text: 'Show all',
size: ButtonSize.small,
onPressed: () {
// TODO: show warning modal
},
)
: null,
child: Column(
children: [
for (final cat in categories.take(5))
if (cat.shown > 0)
_GeneticCategoryRow(category: cat),
if (categories.length > 5)
_ShowMoreRow(
text: 'Show all ${categories.length} categories',
onTap: () {},
),
],
),
);
}
}
class _GeneticCategoryRow extends StatelessWidget {
final GeneticCategory category;
const _GeneticCategoryRow({required this.category});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
const SizedBox(
width: 20,
child: Text('+', style: TextStyle(color: InouTheme.textMuted)),
),
const SizedBox(width: 12),
Expanded(
child: Text(
category.displayName,
style: InouText.body.copyWith(fontWeight: FontWeight.w500),
),
),
Text(
'${category.shown} variants${category.hidden > 0 ? ' (${category.hidden} hidden)' : ''}',
style: InouText.bodySmall.copyWith(color: InouTheme.textMuted),
),
],
),
);
}
}
class _UploadsSection extends StatelessWidget {
final int count;
final String size;
final bool canEdit;
const _UploadsSection({required this.count, required this.size, required this.canEdit});
@override
Widget build(BuildContext context) {
return _DataCard(
title: 'UPLOADS',
indicatorColor: InouTheme.indicatorUploads,
summary: count == 0 ? 'No files' : '$count files, $size',
trailing: InouButton(
text: 'Manage',
size: ButtonSize.small,
variant: canEdit ? ButtonVariant.secondary : ButtonVariant.secondary,
onPressed: canEdit ? () {} : null,
),
);
}
}
class _MedicationsSection extends StatelessWidget {
final List<DataItem> medications;
const _MedicationsSection({required this.medications});
@override
Widget build(BuildContext context) {
return _DataCard(
title: 'MEDICATIONS',
indicatorColor: InouTheme.indicatorMedications,
summary: '${medications.length} medications',
child: Column(
children: [
for (final med in medications) _DataRow(item: med),
],
),
);
}
}
class _SymptomsSection extends StatelessWidget {
final List<DataItem> symptoms;
const _SymptomsSection({required this.symptoms});
@override
Widget build(BuildContext context) {
return _DataCard(
title: 'SYMPTOMS',
indicatorColor: const Color(0xFFF59E0B),
summary: '${symptoms.length} symptoms',
child: Column(
children: [
for (final symptom in symptoms) _DataRow(item: symptom),
],
),
);
}
}
class _HospitalizationsSection extends StatelessWidget {
final List<DataItem> hospitalizations;
const _HospitalizationsSection({required this.hospitalizations});
@override
Widget build(BuildContext context) {
return _DataCard(
title: 'HOSPITALIZATIONS',
indicatorColor: const Color(0xFFEF4444),
summary: '${hospitalizations.length} hospitalizations',
child: Column(
children: [
for (final hosp in hospitalizations) _DataRow(item: hosp),
],
),
);
}
}
class _TherapiesSection extends StatelessWidget {
final List<DataItem> therapies;
const _TherapiesSection({required this.therapies});
@override
Widget build(BuildContext context) {
return _DataCard(
title: 'THERAPIES',
indicatorColor: const Color(0xFF10B981),
summary: '${therapies.length} therapies',
child: Column(
children: [
for (final therapy in therapies) _DataRow(item: therapy),
],
),
);
}
}
class _VitalsSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
return _DataCard(
title: 'VITALS',
indicatorColor: InouTheme.indicatorVitals,
summary: 'Track blood pressure, weight, temperature, and more',
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: InouTheme.bg,
border: Border.all(color: InouTheme.border),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'COMING SOON',
style: InouText.labelCaps.copyWith(color: InouTheme.textMuted),
),
),
comingSoon: true,
);
}
}
class _PrivacySection extends StatelessWidget {
final List<AccessEntry> accessList;
final String dossierId;
final bool canManageAccess;
const _PrivacySection({
required this.accessList,
required this.dossierId,
required this.canManageAccess,
});
@override
Widget build(BuildContext context) {
return _DataCard(
title: 'PRIVACY',
indicatorColor: InouTheme.indicatorPrivacy,
summary: '${accessList.length} people with access',
child: Column(
children: [
for (final access in accessList)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${access.name}${access.isSelf ? ' (you)' : ''}${access.isPending ? ' (pending)' : ''}',
style: InouText.body.copyWith(fontWeight: FontWeight.w500),
),
Text(
'${access.relation}${access.canEdit ? ' · can edit' : ''}',
style: InouText.bodySmall.copyWith(color: InouTheme.textMuted),
),
],
),
),
if (canManageAccess && !access.isSelf) ...[
InouButton(
text: 'Edit',
size: ButtonSize.small,
variant: ButtonVariant.secondary,
onPressed: () {},
),
const SizedBox(width: 8),
InouButton(
text: 'Remove',
size: ButtonSize.small,
variant: ButtonVariant.danger,
onPressed: () {},
),
],
],
),
),
// Privacy actions row
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: InouTheme.bg,
border: Border(top: BorderSide(color: InouTheme.border)),
),
child: Row(
children: [
_PrivacyAction(text: 'Share access', onTap: () {}),
const SizedBox(width: 24),
if (canManageAccess) ...[
_PrivacyAction(text: 'Manage permissions', onTap: () {}),
const SizedBox(width: 24),
],
_PrivacyAction(text: 'View audit log', onTap: () {}),
const SizedBox(width: 24),
_PrivacyAction(text: 'Export data', onTap: () {}),
],
),
),
],
),
);
}
}
class _PrivacyAction extends StatelessWidget {
final String text;
final VoidCallback onTap;
const _PrivacyAction({required this.text, required this.onTap});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Text(
text,
style: InouText.bodySmall.copyWith(color: InouTheme.accent),
),
);
}
}
// ============================================
// SHARED WIDGETS
// ============================================
class _DataCard extends StatelessWidget {
final String title;
final Color indicatorColor;
final String summary;
final Widget? trailing;
final Widget? child;
final bool comingSoon;
const _DataCard({
required this.title,
required this.indicatorColor,
required this.summary,
this.trailing,
this.child,
this.comingSoon = false,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: InouTheme.bgCard,
border: Border.all(color: InouTheme.border),
borderRadius: InouTheme.borderRadiusLg,
),
child: Opacity(
opacity: comingSoon ? 0.6 : 1.0,
child: Column(
children: [
// Header
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// Indicator bar
Container(
width: 4,
height: 32,
decoration: BoxDecoration(
color: indicatorColor,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 12),
// Title and summary
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: InouText.labelCaps.copyWith(
letterSpacing: 0.8,
),
),
const SizedBox(height: 2),
Text(
summary,
style: InouText.bodySmall.copyWith(color: InouTheme.textMuted),
),
],
),
),
if (trailing != null) trailing!,
],
),
),
// Content
if (child != null)
Container(
decoration: BoxDecoration(
border: Border(top: BorderSide(color: InouTheme.border)),
),
child: child,
),
],
),
),
);
}
}
class _DataRow extends StatelessWidget {
final DataItem item;
final bool showType;
const _DataRow({required this.item, this.showType = false});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: InouTheme.border, style: BorderStyle.solid)),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.value,
style: InouText.body.copyWith(fontWeight: FontWeight.w500),
),
if (item.summary != null)
Text(
item.summary!,
style: InouText.bodySmall.copyWith(color: InouTheme.textMuted),
),
],
),
),
if (showType && item.type != null) ...[
Text(
item.type!,
style: InouText.bodySmall.copyWith(color: InouTheme.textMuted),
),
const SizedBox(width: 16),
],
if (item.date != null)
Text(
item.date!,
style: InouText.mono.copyWith(color: InouTheme.textMuted),
),
],
),
);
}
}
class _ShowMoreRow extends StatelessWidget {
final String text;
final VoidCallback onTap;
const _ShowMoreRow({required this.text, required this.onTap});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
alignment: Alignment.center,
decoration: BoxDecoration(
border: Border(top: BorderSide(color: InouTheme.border)),
),
child: Text(
text,
style: InouText.bodySmall.copyWith(color: InouTheme.textMuted),
),
),
);
}
}