This commit is contained in:
Dwi Swandhana
2026-07-21 19:08:27 +07:00
parent d944647129
commit 6d0f9a8bf6
26 changed files with 6405 additions and 1312 deletions

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
part '../providers/session_provider.dart';
part '../services/api_client.dart';
part '../services/session_store.dart';
part '../screens/login/login_screen.dart';
part '../screens/dashboard/dashboard_screen.dart';
part '../screens/examinations/examination_list_screen.dart';
part '../screens/examinations/examination_detail_screen.dart';
part '../screens/expertise/expertise_screen.dart';
part '../screens/expertise/template_picker.dart';
part '../screens/expertise/cci_expertise_wizard.dart';
part '../screens/expertise/kultur_expertise_wizard.dart';
part '../screens/expertise/pewarnaan_langsung_expertise_wizard.dart';
part '../screens/expertise/viral_load_expertise_wizard.dart';
part '../screens/expertise/leptospira_expertise_wizard.dart';
part '../screens/expertise/covid_expertise_wizard.dart';
part '../screens/expertise/tbc_expertise_wizard.dart';
part '../screens/expertise/expertise_form.dart';
part '../data/expertise_data.dart';
part '../widgets/common_widgets.dart';
const String kDefaultBaseUrl = String.fromEnvironment(
'LIS_BASE_URL',
defaultValue: 'https://lis.swandhana.test/',
);
class MyLisApp extends StatelessWidget {
const MyLisApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'MyLIS Mobile',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF0F766E),
brightness: Brightness.light,
),
scaffoldBackgroundColor: const Color(0xFFF7FAF9),
useMaterial3: true,
),
home: const SessionGate(),
);
}
}
+29
View File
@@ -0,0 +1,29 @@
part of '../app/app.dart';
const Set<String> _knownDlp = {
'CCI',
'Kultur',
'Pewarna Langsung',
'TBC',
'Viral Load',
'IgM IgG Leptospira',
'PCR COVID',
};
String _templateTitle(String dlp) {
return switch (dlp) {
'CCI' => 'Pemeriksaan Candida Colonization Index',
'Kultur' => 'Kultur',
'Pewarna Langsung' => 'Pewarna Langsung',
'TBC' => 'TBC',
'Viral Load' => 'Viral Load',
'IgM IgG Leptospira' => 'IgM IgG Leptospira',
'PCR COVID' => 'PCR COVID',
_ => dlp,
};
}
bool _isSupervisor(String? role) {
final normalized = role?.toLowerCase().trim() ?? '';
return normalized.contains('supervisor') || normalized == 'spv';
}
+2 -1268
View File
File diff suppressed because it is too large Load diff
+35
View File
@@ -0,0 +1,35 @@
part of '../app/app.dart';
class SessionGate extends StatefulWidget {
const SessionGate({super.key});
@override
State<SessionGate> createState() => _SessionGateState();
}
class _SessionGateState extends State<SessionGate> {
late final Future<SessionData> _sessionFuture;
@override
void initState() {
super.initState();
_sessionFuture = SessionStore.session();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<SessionData>(
future: _sessionFuture,
builder: (context, snapshot) {
if (!snapshot.hasData &&
snapshot.connectionState != ConnectionState.done) {
return const LoadingScreen();
}
final session = snapshot.data ?? const SessionData();
return session.token == null
? const LoginScreen()
: DashboardScreen(token: session.token!, baseUrl: session.baseUrl);
},
);
}
}
@@ -0,0 +1,306 @@
part of '../../app/app.dart';
class DashboardScreen extends StatefulWidget {
const DashboardScreen({
super.key,
required this.token,
required this.baseUrl,
});
final String token;
final String baseUrl;
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
late final ApiClient _api;
late Future<Map<String, dynamic>> _future;
@override
void initState() {
super.initState();
_api = ApiClient(baseUrl: widget.baseUrl, token: widget.token);
_future = _api.get('api/mobile/dashboard');
}
void _reload() {
setState(() => _future = _api.get('api/mobile/dashboard'));
}
Future<void> _logout() async {
await SessionStore.clear();
if (!mounted) {
return;
}
Navigator.of(
context,
).pushReplacement(MaterialPageRoute(builder: (_) => const LoginScreen()));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Dashboard'),
actions: [
IconButton(
onPressed: _reload,
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
IconButton(
onPressed: _logout,
icon: const Icon(Icons.logout),
tooltip: 'Logout',
),
],
),
body: RefreshIndicator(
onRefresh: () async => _reload(),
child: FutureBuilder<Map<String, dynamic>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const LoadingScreen();
}
if (snapshot.hasError) {
return ErrorView(
message: _message(snapshot.error),
onRetry: _reload,
);
}
final data = snapshot.data!;
final user = asMap(data['user']);
final summary = asMap(data['summary']);
final warnings = asList(data['early_warning_groups']);
final books = asList(data['books']);
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
HeaderPanel(user: user, summary: summary),
const SizedBox(height: 16),
SectionTitle(
title: 'Early Warning Sistem',
actionLabel:
'${warnings.fold<int>(0, (total, item) => total + (asMap(item)['total'] as num? ?? 0).toInt())} kasus',
),
if (warnings.isEmpty)
const EmptyPanel(text: 'Tidak ada data Early Warning.')
else
...warnings.map(
(group) => EarlyWarningGroupCard(
group: asMap(group),
token: widget.token,
baseUrl: widget.baseUrl,
),
),
const SizedBox(height: 18),
const SectionTitle(title: 'List Pemeriksaan'),
...books.map(
(book) => BookTile(
book: asMap(book),
token: widget.token,
baseUrl: widget.baseUrl,
),
),
],
);
},
),
),
);
}
}
class HeaderPanel extends StatelessWidget {
const HeaderPanel({super.key, required this.user, required this.summary});
final Map<String, dynamic> user;
final Map<String, dynamic> summary;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF0F766E),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user['nama']?.toString() ?? '-',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
Text(
user['previlage']?.toString() ?? '-',
style: const TextStyle(color: Colors.white70),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: SummaryItem(label: 'PPDS', value: summary['total_ppds']),
),
Expanded(
child: SummaryItem(
label: 'Verifikasi',
value: summary['butuh_verifikasi'],
),
),
Expanded(
child: SummaryItem(
label: 'Hari Ini',
value: summary['antrian_hari_ini'],
),
),
],
),
],
),
);
}
}
class SummaryItem extends StatelessWidget {
const SummaryItem({super.key, required this.label, required this.value});
final String label;
final Object? value;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${value ?? 0}',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w800,
),
),
Text(
label,
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
],
);
}
}
class SectionTitle extends StatelessWidget {
const SectionTitle({super.key, required this.title, this.actionLabel});
final String title;
final String? actionLabel;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Expanded(
child: Text(
title,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800),
),
),
if (actionLabel != null)
BadgeLabel(text: actionLabel!, color: const Color(0xFFDC2626)),
],
),
);
}
}
class EarlyWarningGroupCard extends StatelessWidget {
const EarlyWarningGroupCard({
super.key,
required this.group,
required this.token,
required this.baseUrl,
});
final Map<String, dynamic> group;
final String token;
final String baseUrl;
@override
Widget build(BuildContext context) {
final items = asList(group['items']);
return Card(
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: ExpansionTile(
leading: const Icon(
Icons.warning_amber_rounded,
color: Color(0xFFDC2626),
),
title: Text(
group['subpoli']?.toString() ?? 'Tanpa Subpoli',
style: const TextStyle(fontWeight: FontWeight.w700),
),
subtitle: Text('${group['total'] ?? items.length} kasus warning'),
children: items
.map(
(item) => ExaminationCompactTile(
item: asMap(item),
token: token,
baseUrl: baseUrl,
),
)
.toList(),
),
);
}
}
class BookTile extends StatelessWidget {
const BookTile({
super.key,
required this.book,
required this.token,
required this.baseUrl,
});
final Map<String, dynamic> book;
final String token;
final String baseUrl;
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: ListTile(
leading: const CircleAvatar(
backgroundColor: Color(0xFFE0F2F1),
child: Icon(Icons.science_outlined, color: Color(0xFF0F766E)),
),
title: Text(book['label']?.toString() ?? '-'),
subtitle: Text('${book['total'] ?? 0} pemeriksaan aktif'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ExaminationListScreen(
token: token,
baseUrl: baseUrl,
master: book['master'].toString(),
title: book['label']?.toString() ?? 'Pemeriksaan',
),
),
);
},
),
);
}
}
@@ -0,0 +1,171 @@
part of '../../app/app.dart';
class ExaminationDetailScreen extends StatefulWidget {
const ExaminationDetailScreen({
super.key,
required this.token,
required this.baseUrl,
required this.id,
});
final String token;
final String baseUrl;
final int id;
@override
State<ExaminationDetailScreen> createState() =>
_ExaminationDetailScreenState();
}
class _ExaminationDetailScreenState extends State<ExaminationDetailScreen> {
late final ApiClient _api;
late Future<Map<String, dynamic>> _future;
@override
void initState() {
super.initState();
_api = ApiClient(baseUrl: widget.baseUrl, token: widget.token);
_future = _api.get('api/mobile/examinations/${widget.id}');
}
void _reload() {
setState(() => _future = _api.get('api/mobile/examinations/${widget.id}'));
}
Future<void> _launch(String? url) async {
if (url == null || url.isEmpty) {
return;
}
final uri = Uri.parse(url);
if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Halaman tidak bisa dibuka.')),
);
}
}
Future<void> _openExpertise() async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ExpertiseScreen(
token: widget.token,
baseUrl: widget.baseUrl,
id: widget.id,
),
),
);
_reload();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Detail Pemeriksaan'),
actions: [
IconButton(
onPressed: _reload,
icon: const Icon(Icons.refresh),
tooltip: 'Cek status',
),
],
),
body: FutureBuilder<Map<String, dynamic>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const LoadingScreen();
}
if (snapshot.hasError) {
return ErrorView(
message: _message(snapshot.error),
onRetry: _reload,
);
}
final data = snapshot.data!;
final item = asMap(data['item']);
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
item['nmpasien']?.toString() ?? '-',
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.w800),
),
),
StatusPill(
status: item['status']?.toString() ?? 'NEW',
),
],
),
const SizedBox(height: 16),
DetailRow(label: 'No. Lab', value: item['nofoto']),
DetailRow(label: 'No. RM', value: item['noregister']),
DetailRow(label: 'Order', value: item['reques']),
DetailRow(
label: 'Asal Pasien',
value: item['asalpasien'],
),
DetailRow(label: 'Ruangan', value: item['ruangan']),
DetailRow(
label: 'Dokter Pengirim',
value: item['klinisi'] ?? item['nmdokter'],
),
DetailRow(label: 'Spesimen', value: item['nm_spesimen']),
DetailRow(label: 'Tanggal Daftar', value: item['daftar']),
DetailRow(
label: 'Tanggal Sampel',
value: item['tanggalsampel'],
),
DetailRow(
label: 'Cara Pengambilan',
value: item['pengambilan'],
),
DetailRow(
label: 'Asal Pengambilan',
value: item['asalpengirim'],
),
DetailRow(label: 'Alamat', value: item['alamatpasien']),
],
),
),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _openExpertise,
icon: const Icon(Icons.edit_document),
label: const Text('Expertise'),
),
OutlinedButton.icon(
onPressed: _reload,
icon: const Icon(Icons.fact_check_outlined),
label: const Text('Cek Status'),
),
if (data['result_url'] != null)
TextButton.icon(
onPressed: () => _launch(data['result_url']?.toString()),
icon: const Icon(Icons.description_outlined),
label: const Text('Preview Hasil'),
),
],
);
},
),
);
}
}
@@ -0,0 +1,237 @@
part of '../../app/app.dart';
class ExaminationListScreen extends StatefulWidget {
const ExaminationListScreen({
super.key,
required this.token,
required this.baseUrl,
required this.master,
required this.title,
});
final String token;
final String baseUrl;
final String master;
final String title;
@override
State<ExaminationListScreen> createState() => _ExaminationListScreenState();
}
class _ExaminationListScreenState extends State<ExaminationListScreen> {
late final ApiClient _api;
late Future<Map<String, dynamic>> _future;
final _search = TextEditingController();
@override
void initState() {
super.initState();
_api = ApiClient(baseUrl: widget.baseUrl, token: widget.token);
_future = _load();
}
@override
void dispose() {
_search.dispose();
super.dispose();
}
Future<Map<String, dynamic>> _load() {
final query = _search.text.trim();
return _api.get(
'api/mobile/books/${widget.master}/examinations',
query.isEmpty ? null : {'search': query},
);
}
void _reload() {
setState(() => _future = _load());
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
actions: [
IconButton(
onPressed: _reload,
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 10),
child: TextField(
controller: _search,
textInputAction: TextInputAction.search,
onSubmitted: (_) => _reload(),
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
labelText: 'Cari no lab, RM, pasien, order',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
onPressed: _reload,
icon: const Icon(Icons.arrow_forward),
tooltip: 'Cari',
),
),
),
),
Expanded(
child: FutureBuilder<Map<String, dynamic>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const LoadingScreen();
}
if (snapshot.hasError) {
return ErrorView(
message: _message(snapshot.error),
onRetry: _reload,
);
}
final items = asList(snapshot.data!['items']);
if (items.isEmpty) {
return const EmptyPanel(text: 'Tidak ada pemeriksaan aktif.');
}
return RefreshIndicator(
onRefresh: () async => _reload(),
child: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
itemCount: items.length,
itemBuilder: (context, index) => ExaminationCard(
item: asMap(items[index]),
token: widget.token,
baseUrl: widget.baseUrl,
),
),
);
},
),
),
],
),
);
}
}
class ExaminationCompactTile extends StatelessWidget {
const ExaminationCompactTile({
super.key,
required this.item,
required this.token,
required this.baseUrl,
});
final Map<String, dynamic> item;
final String token;
final String baseUrl;
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(
item['nmpasien']?.toString() ?? '-',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${item['nofoto'] ?? '-'} - ${item['warning_label'] ?? item['status'] ?? '-'}',
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _openDetail(context, item, token, baseUrl),
);
}
}
class ExaminationCard extends StatelessWidget {
const ExaminationCard({
super.key,
required this.item,
required this.token,
required this.baseUrl,
});
final Map<String, dynamic> item;
final String token;
final String baseUrl;
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => _openDetail(context, item, token, baseUrl),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
item['nmpasien']?.toString() ?? '-',
style: const TextStyle(fontWeight: FontWeight.w800),
),
),
StatusPill(status: item['status']?.toString() ?? 'NEW'),
],
),
const SizedBox(height: 8),
Text(
'${item['nofoto'] ?? '-'} / RM ${item['noregister'] ?? '-'}',
),
Text(
item['reques']?.toString() ?? '-',
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 10),
Row(
children: [
const Icon(Icons.schedule, size: 16, color: Colors.black54),
const SizedBox(width: 6),
Expanded(
child: Text(
item['daftar']?.toString() ?? '-',
style: const TextStyle(color: Colors.black54),
),
),
TextButton.icon(
onPressed: () => _openDetail(context, item, token, baseUrl),
icon: const Icon(Icons.edit_note),
label: const Text('Expertise'),
),
],
),
],
),
),
),
);
}
}
Future<void> _openDetail(
BuildContext context,
Map<String, dynamic> item,
String token,
String baseUrl,
) async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ExaminationDetailScreen(
token: token,
baseUrl: baseUrl,
id: (item['id'] as num).toInt(),
),
),
);
}
@@ -0,0 +1,448 @@
part of '../../app/app.dart';
class CciExpertiseWizard extends StatefulWidget {
const CciExpertiseWizard({
super.key,
required this.item,
required this.user,
required this.staffOptions,
required this.optionSets,
required this.textControllers,
required this.selectValues,
required this.staffValues,
required this.criticalValue,
required this.isSupervisor,
required this.saving,
required this.onCriticalChanged,
required this.onChanged,
required this.onSave,
});
final Map<String, dynamic> item;
final Map<String, dynamic> user;
final Map<String, dynamic> staffOptions;
final Map<String, dynamic> optionSets;
final Map<String, TextEditingController> textControllers;
final Map<String, String> selectValues;
final Map<String, String> staffValues;
final bool criticalValue;
final bool isSupervisor;
final bool saving;
final ValueChanged<bool> onCriticalChanged;
final VoidCallback onChanged;
final ValueChanged<String> onSave;
@override
State<CciExpertiseWizard> createState() => _CciExpertiseWizardState();
}
class _CciExpertiseWizardState extends State<CciExpertiseWizard> {
int _step = 0;
void _next() {
setState(() => _step = (_step + 1).clamp(0, 3));
}
void _previous() {
setState(() => _step = (_step - 1).clamp(0, 3));
}
@override
Widget build(BuildContext context) {
return Stepper(
currentStep: _step,
onStepTapped: (step) => setState(() => _step = step),
controlsBuilder: (context, details) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
OutlinedButton.icon(
onPressed: _step == 0 ? null : _previous,
icon: const Icon(Icons.chevron_left),
label: const Text('Previous'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _step == 3 ? null : _next,
icon: const Icon(Icons.chevron_right),
label: const Text('Next'),
),
],
),
);
},
steps: [
Step(
title: const Text('Pasien & Petugas'),
isActive: _step == 0,
content: Column(
children: [
PatientStaffPanel(
item: widget.item,
user: widget.user,
staffOptions: widget.staffOptions,
staffValues: widget.staffValues,
onChanged: widget.onChanged,
),
],
),
),
Step(
title: const Text('CCI'),
isActive: _step == 1,
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Pemeriksaan Candida Colonization Index',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800),
),
const SizedBox(height: 12),
_select(
'id_sputum',
'Sputum',
_options('jsonsputum', ['Pilih Salah Satu']),
),
_select(
'id_swabtenggorok',
'Swab Tenggorok',
_options('jsonswabtenggorok', ['Pilih Salah Satu']),
),
_select(
'id_urine',
'Urine',
_options('jsonurine', ['Pilih Salah Satu']),
),
_select(
'id_swabperineum',
'Swab Perineum',
_options('jsonswabperineum', ['Pilih Salah Satu']),
),
],
),
),
Step(
title: const Text('Data Alat'),
isActive: _step == 2,
content: Column(
children: [
_toolSection(
title: 'Vitek',
bacteriaField: 'bakteri',
sirField: 'bakterisir',
colonyField: 'id_bakterihitungkol',
colonyTextField: 'id_bakterihitungkolteks',
printField: 'id_baktericetak',
),
_toolSection(
title: 'MALDITOF',
bacteriaField: 'id_bakteri01',
antibioticField: 'id_antibiotikmanual1',
sirField: 'id_bakterisir01',
colonyField: 'id_bakterihitungkol01',
colonyTextField: 'id_bakterihitungkolteks01',
printField: 'id_bakteri01cetak',
),
_toolSection(
title: 'Bakteri Manual I',
bacteriaField: 'id_bakteri02',
antibioticField: 'id_antibiotikmanual2',
sirField: 'id_bakterisir02',
colonyField: 'id_bakterihitungkol02',
colonyTextField: 'id_bakterihitungkolteks02',
printField: 'id_bakteri02cetak',
),
],
),
),
Step(
title: const Text('Final'),
isActive: _step == 3,
content: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_text('keterangan', 'Expertise', minLines: 8, maxLines: 12),
CheckboxListTile(
value: widget.criticalValue,
onChanged: (value) => widget.onCriticalChanged(value ?? false),
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: const Text('Nilai Kritis'),
),
FilledButton.icon(
onPressed: widget.saving ? null : () => widget.onSave('Draft'),
icon: widget.saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined),
label: const Text('Save as Draft'),
),
if (widget.isSupervisor) ...[
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('preliminary'),
icon: const Icon(Icons.done_all_outlined),
label: const Text('Save Preliminary result'),
),
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('verifikasi'),
icon: const Icon(Icons.verified_outlined),
label: const Text('Save Final Result'),
),
] else ...[
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('Permohonan Verifikasi'),
icon: const Icon(Icons.send_outlined),
label: const Text('Save and Send To SPV'),
),
OutlinedButton.icon(
onPressed: widget.saving
? null
: () =>
widget.onSave('Permohonan Verifikasi Preliminary'),
icon: const Icon(Icons.outgoing_mail),
label: const Text('Kirim SPV Preliminary'),
),
],
],
),
),
],
);
}
List<String> _options(String key, List<String> fallback) {
final values = asList(
widget.optionSets[key],
).map((item) => item.toString()).where((item) => item.isNotEmpty).toList();
return values.isEmpty ? fallback : values;
}
Widget _toolSection({
required String title,
required String bacteriaField,
String? antibioticField,
required String sirField,
required String colonyField,
required String colonyTextField,
required String printField,
}) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800),
),
const SizedBox(height: 10),
_text(bacteriaField, 'Bakteri'),
if (antibioticField != null)
_text(antibioticField, 'Set Antibiotik'),
_select(sirField, 'SIR', _sirOptions),
_select(colonyField, 'Hitung Koloni', _colonyOptions),
_text(colonyTextField, 'Hitung Koloni Lainnya'),
_select(printField, 'Cetak', const ['YA', 'TIDAK']),
],
),
),
);
}
Widget _select(String name, String label, List<String> options) {
final current = widget.selectValues[name];
final effectiveOptions = [
if (current != null && current.isNotEmpty && !options.contains(current))
current,
...options,
];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: DropdownButtonFormField<String>(
initialValue:
current != null &&
current.isNotEmpty &&
effectiveOptions.contains(current)
? current
: null,
isExpanded: true,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
items: effectiveOptions
.map(
(option) => DropdownMenuItem(value: option, child: Text(option)),
)
.toList(),
onChanged: (value) {
widget.selectValues[name] = value ?? '';
widget.onChanged();
},
),
);
}
Widget _text(
String name,
String label, {
int minLines = 1,
int maxLines = 1,
}) {
final controller = widget.textControllers[name] ??= TextEditingController();
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
controller: controller,
minLines: minLines,
maxLines: maxLines,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
),
);
}
}
class PatientStaffPanel extends StatelessWidget {
const PatientStaffPanel({
super.key,
required this.item,
required this.user,
required this.staffOptions,
required this.staffValues,
required this.onChanged,
});
final Map<String, dynamic> item;
final Map<String, dynamic> user;
final Map<String, dynamic> staffOptions;
final Map<String, String> staffValues;
final VoidCallback onChanged;
@override
Widget build(BuildContext context) {
return Column(
children: [
Card(
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
DetailRow(label: 'Order', value: item['reques']),
DetailRow(
label: 'Dokter Pengirim',
value: item['klinisi'] ?? item['nmdokter'],
),
DetailRow(label: 'Asal Pasien', value: item['asalpasien']),
DetailRow(label: 'No. Lokal Lab', value: item['nofoto']),
DetailRow(label: 'Register Number', value: item['noregister']),
DetailRow(label: 'Name', value: item['nmpasien']),
DetailRow(
label: 'Gender, Age',
value: '${item['jkpasien'] ?? '-'}, ${item['usia'] ?? '-'}',
),
DetailRow(label: 'Phone', value: item['tlppasien']),
DetailRow(label: 'Address', value: item['alamatpasien']),
DetailRow(
label: 'Tanggal Registrasi',
value: item['tanggalregis'] ?? item['daftar'],
),
DetailRow(
label: 'Tanggal Pengambilan',
value: item['tanggalsampel'],
),
DetailRow(
label: 'Cara Pengambilan',
value: item['pengambilan'],
),
DetailRow(
label: 'Asal Pengambilan',
value: item['asalpengirim'],
),
DetailRow(
label: 'Spesimen',
value: item['kesimpulan'] ?? item['nm_spesimen'],
),
DetailRow(label: 'Status', value: item['status']),
],
),
),
),
const SizedBox(height: 12),
_staffSelect('analis', 'ATLM', asList(staffOptions['analis'])),
_staffSelect('ppds3', 'PPDS', asList(staffOptions['ppds'])),
_staffSelect('dokter', 'SPV', asList(staffOptions['dokters'])),
DetailRow(label: 'Klinis/Diagnosis', value: item['klinis']),
],
);
}
Widget _staffSelect(String name, String label, List<dynamic> users) {
final current = staffValues[name];
final items = users.map(asMap).toList();
final hasCurrent = items.any((item) => item['id'].toString() == current);
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: DropdownButtonFormField<String>(
initialValue: hasCurrent ? current : null,
isExpanded: true,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
items: [
const DropdownMenuItem(value: '0', child: Text('Pilih')),
...items.map(
(item) => DropdownMenuItem(
value: item['id'].toString(),
child: Text(item['nama']?.toString() ?? '-'),
),
),
],
onChanged: (value) {
staffValues[name] = value ?? '0';
onChanged();
},
),
);
}
}
const List<String> _sirOptions = [
'ESBL',
'MRSA',
'MDR',
'XDR',
'PDR',
'Carbapenem Resistant',
'MDR Carbapenem Resistant',
'XDR Carbapenem Resistant',
];
const List<String> _colonyOptions = [
'>= 10^5 CFU/ml Urine (Bakteriuria bermakna)',
'>= 10^5 CFU/ml Urine (Candiduria bermakna)',
'>= 10^3 CFU/ml Urine (Bakteriuria bermakna)',
'8 x 10^3 CFU/ml Urine (Bakteriuria bermakna)',
'lainnya',
];
@@ -0,0 +1,418 @@
part of '../../app/app.dart';
class CovidExpertiseWizard extends StatefulWidget {
const CovidExpertiseWizard({
super.key,
required this.item,
required this.user,
required this.staffOptions,
required this.textControllers,
required this.selectValues,
required this.staffValues,
required this.criticalValue,
required this.isSupervisor,
required this.saving,
required this.onCriticalChanged,
required this.onChanged,
required this.onSave,
});
final Map<String, dynamic> item;
final Map<String, dynamic> user;
final Map<String, dynamic> staffOptions;
final Map<String, TextEditingController> textControllers;
final Map<String, String> selectValues;
final Map<String, String> staffValues;
final bool criticalValue;
final bool isSupervisor;
final bool saving;
final ValueChanged<bool> onCriticalChanged;
final VoidCallback onChanged;
final ValueChanged<String> onSave;
@override
State<CovidExpertiseWizard> createState() => _CovidExpertiseWizardState();
}
class _CovidExpertiseWizardState extends State<CovidExpertiseWizard> {
int _step = 0;
void _next() {
setState(() => _step = (_step + 1).clamp(0, 4));
}
void _previous() {
setState(() => _step = (_step - 1).clamp(0, 4));
}
@override
Widget build(BuildContext context) {
return Stepper(
currentStep: _step,
onStepTapped: (step) => setState(() => _step = step),
controlsBuilder: (context, details) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
OutlinedButton.icon(
onPressed: _step == 0 ? null : _previous,
icon: const Icon(Icons.chevron_left),
label: const Text('Previous'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _step == 4 ? null : _next,
icon: const Icon(Icons.chevron_right),
label: const Text('Next'),
),
],
),
);
},
steps: [
Step(
title: const Text('Pasien & Petugas'),
isActive: _step == 0,
content: PatientStaffPanel(
item: widget.item,
user: widget.user,
staffOptions: widget.staffOptions,
staffValues: widget.staffValues,
onChanged: widget.onChanged,
),
),
Step(
title: const Text('Parameter'),
isActive: _step == 1,
content: _parameters(),
),
Step(
title: const Text('Tes Kepekaan Antibiotik'),
isActive: _step == 2,
content: _antibioticSensitivity(),
),
Step(
title: const Text('Data Alat'),
isActive: _step == 3,
content: _toolData(),
),
Step(
title: const Text('Expertise'),
isActive: _step == 4,
content: _finalExpertise(),
),
],
);
}
Widget _parameters() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('PCR COVID'),
for (final virus in _covidVirusFields)
Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(virus.label, compact: true),
_select(virus.resultField, virus.label, _covidResultOptions),
_text(virus.unitField, 'Satuan'),
_text(virus.referenceField, 'Nilai Rujukan'),
],
),
),
),
_text('covid_interpretasi', 'Interpretasi', minLines: 6, maxLines: 9),
],
);
}
Widget _antibioticSensitivity() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Tes Kepekaan Antibiotik'),
Text(
'S: Sensitif, I: Intermediate, R: Resisten',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: const [
BadgeLabel(text: 'Media BAP', color: Color(0xFF2563EB)),
BadgeLabel(text: 'Media CAP', color: Color(0xFFDC2626)),
BadgeLabel(text: 'Mc Conkey', color: Color(0xFF0891B2)),
BadgeLabel(text: 'Media Jamur', color: Color(0xFFD97706)),
],
),
const SizedBox(height: 12),
for (final media in _cultureMedia)
Card(
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(media.label, compact: true),
_select(
media.statusField,
'Status Pertumbuhan',
_growthOptions,
),
_text(media.noteField, 'Catatan'),
],
),
),
),
],
);
}
Widget _toolData() {
return Column(
children: [
_toolSection(
title: 'Vitek',
bacteriaField: 'bakteri',
sirField: 'bakterisir',
colonyField: 'id_bakterihitungkol',
colonyTextField: 'id_bakterihitungkolteks',
printField: 'id_baktericetak',
),
_toolSection(
title: 'MALDITOF',
bacteriaField: 'id_bakteri01',
antibioticField: 'id_antibiotikmanual1',
sirField: 'id_bakterisir01',
colonyField: 'id_bakterihitungkol01',
colonyTextField: 'id_bakterihitungkolteks01',
printField: 'id_bakteri01cetak',
),
_toolSection(
title: 'Bakteri Manual I',
bacteriaField: 'id_bakteri02',
antibioticField: 'id_antibiotikmanual2',
sirField: 'id_bakterisir02',
colonyField: 'id_bakterihitungkol02',
colonyTextField: 'id_bakterihitungkolteks02',
printField: 'id_bakteri02cetak',
),
],
);
}
Widget _finalExpertise() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_text('keterangan', 'Expertise', minLines: 8, maxLines: 12),
CheckboxListTile(
value: widget.criticalValue,
onChanged: (value) => widget.onCriticalChanged(value ?? false),
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: const Text('Nilai Kritis'),
),
FilledButton.icon(
onPressed: widget.saving ? null : () => widget.onSave('Draft'),
icon: widget.saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined),
label: const Text('Save as Draft'),
),
if (widget.isSupervisor) ...[
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('preliminary'),
icon: const Icon(Icons.done_all_outlined),
label: const Text('Save Preliminary result'),
),
OutlinedButton.icon(
onPressed: widget.saving ? null : () => widget.onSave('verifikasi'),
icon: const Icon(Icons.verified_outlined),
label: const Text('Save Final Result'),
),
] else ...[
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('Permohonan Verifikasi'),
icon: const Icon(Icons.send_outlined),
label: const Text('Save and Send To SPV'),
),
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('Permohonan Verifikasi Preliminary'),
icon: const Icon(Icons.outgoing_mail),
label: const Text('Kirim SPV Preliminary'),
),
],
],
);
}
Widget _sectionTitle(String title, {bool compact = false}) {
return Padding(
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800,
color: const Color(0xFF0F766E),
),
),
);
}
Widget _toolSection({
required String title,
required String bacteriaField,
String? antibioticField,
required String sirField,
required String colonyField,
required String colonyTextField,
required String printField,
}) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(title, compact: true),
_text(bacteriaField, 'Bakteri'),
if (antibioticField != null)
_text(antibioticField, 'Set Antibiotik'),
_select(sirField, 'SIR', _sirOptions),
_select(colonyField, 'Hitung Koloni', _colonyOptions),
_text(colonyTextField, 'Hitung Koloni Lainnya'),
_select(printField, 'Cetak', const ['YA', 'TIDAK']),
],
),
),
);
}
Widget _select(String name, String label, List<String> options) {
final current = widget.selectValues[name];
final effectiveOptions = [
if (current != null && current.isNotEmpty && !options.contains(current))
current,
...options,
];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: DropdownButtonFormField<String>(
initialValue:
current != null &&
current.isNotEmpty &&
effectiveOptions.contains(current)
? current
: null,
isExpanded: true,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
items: effectiveOptions
.map(
(option) => DropdownMenuItem(value: option, child: Text(option)),
)
.toList(),
onChanged: (value) {
widget.selectValues[name] = value ?? '';
widget.onChanged();
},
),
);
}
Widget _text(
String name,
String label, {
int minLines = 1,
int maxLines = 1,
}) {
final controller = widget.textControllers[name] ??= TextEditingController();
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
controller: controller,
minLines: minLines,
maxLines: maxLines,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
),
);
}
}
class _CovidVirusField {
const _CovidVirusField(
this.label,
this.resultField,
this.unitField,
this.referenceField,
);
final String label;
final String resultField;
final String unitField;
final String referenceField;
}
const List<String> _covidResultOptions = [
'Positif',
'Negatif',
'Tidak Diperiksa',
];
const List<_CovidVirusField> _covidVirusFields = [
_CovidVirusField(
'Virus SARS-Cov-2',
'covid_virus01',
'covid_satuanvirus01',
'covid_rujukanvirus01',
),
_CovidVirusField(
'Influenza A',
'covid_virus02',
'covid_satuanvirus02',
'covid_rujukanvirus02',
),
_CovidVirusField(
'Influenza B',
'covid_virus03',
'covid_satuanvirus03',
'covid_rujukanvirus03',
),
_CovidVirusField(
'RSV',
'covid_virus04',
'covid_satuanvirus04',
'covid_rujukanvirus04',
),
];
@@ -0,0 +1,192 @@
part of '../../app/app.dart';
class ExpertiseForm extends StatelessWidget {
const ExpertiseForm({
super.key,
required this.dlp,
required this.fields,
required this.textControllers,
required this.selectValues,
required this.multiValues,
required this.onChanged,
});
final String dlp;
final List<dynamic> fields;
final Map<String, TextEditingController> textControllers;
final Map<String, String> selectValues;
final Map<String, Set<String>> multiValues;
final VoidCallback onChanged;
@override
Widget build(BuildContext context) {
return Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_templateTitle(dlp),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800),
),
const SizedBox(height: 12),
...fields.map((rawField) => _buildField(context, asMap(rawField))),
],
),
),
);
}
Widget _buildField(BuildContext context, Map<String, dynamic> field) {
final name = field['name']?.toString() ?? '';
final label = field['label']?.toString() ?? name;
final type = field['type']?.toString() ?? 'text';
final options = asList(
field['options'],
).map((item) => item.toString()).toList();
if (type == 'select') {
final currentValue = options.contains(selectValues[name])
? selectValues[name]
: null;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: DropdownButtonFormField<String>(
initialValue: currentValue,
isExpanded: true,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
items: options
.map(
(option) =>
DropdownMenuItem(value: option, child: Text(option)),
)
.toList(),
onChanged: (value) {
selectValues[name] = value ?? '';
onChanged();
},
),
);
}
if (type == 'multiselect') {
final selected = multiValues[name] ?? <String>{};
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
child: Wrap(
spacing: 8,
runSpacing: 4,
children: options
.map(
(option) => FilterChip(
label: Text(option),
selected: selected.contains(option),
onSelected: (checked) {
if (checked) {
selected.add(option);
} else {
selected.remove(option);
}
multiValues[name] = selected;
onChanged();
},
),
)
.toList(),
),
),
);
}
final controller = textControllers[name] ??= TextEditingController();
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
controller: controller,
minLines: type == 'textarea' ? 4 : 1,
maxLines: type == 'textarea' ? 8 : 1,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
),
);
}
}
class ExpertiseActions extends StatelessWidget {
const ExpertiseActions({
super.key,
required this.isSupervisor,
required this.saving,
required this.onSave,
});
final bool isSupervisor;
final bool saving;
final ValueChanged<String> onSave;
@override
Widget build(BuildContext context) {
return Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton.icon(
onPressed: saving ? null : () => onSave('Draft'),
icon: saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined),
label: const Text('Save as Draft'),
),
if (isSupervisor) ...[
OutlinedButton.icon(
onPressed: saving ? null : () => onSave('preliminary'),
icon: const Icon(Icons.done_all_outlined),
label: const Text('Save Preliminary result'),
),
OutlinedButton.icon(
onPressed: saving ? null : () => onSave('verifikasi'),
icon: const Icon(Icons.verified_outlined),
label: const Text('Save Final Result'),
),
] else ...[
OutlinedButton.icon(
onPressed: saving
? null
: () => onSave('Permohonan Verifikasi'),
icon: const Icon(Icons.send_outlined),
label: const Text('Save and Send To SPV'),
),
OutlinedButton.icon(
onPressed: saving
? null
: () => onSave('Permohonan Verifikasi Preliminary'),
icon: const Icon(Icons.outgoing_mail),
label: const Text('Kirim SPV Preliminary'),
),
],
],
),
),
);
}
}
@@ -0,0 +1,391 @@
part of '../../app/app.dart';
class ExpertiseScreen extends StatefulWidget {
const ExpertiseScreen({
super.key,
required this.token,
required this.baseUrl,
required this.id,
});
final String token;
final String baseUrl;
final int id;
@override
State<ExpertiseScreen> createState() => _ExpertiseScreenState();
}
class _ExpertiseScreenState extends State<ExpertiseScreen> {
late final ApiClient _api;
late Future<Map<String, dynamic>> _future;
final Map<String, TextEditingController> _textControllers = {};
final Map<String, String> _selectValues = {};
final Map<String, Set<String>> _multiValues = {};
final Map<String, String> _staffValues = {};
String _currentDlp = '';
bool _criticalValue = false;
bool _saving = false;
@override
void initState() {
super.initState();
_api = ApiClient(baseUrl: widget.baseUrl, token: widget.token);
_future = _load();
}
@override
void dispose() {
for (final controller in _textControllers.values) {
controller.dispose();
}
super.dispose();
}
Future<Map<String, dynamic>> _load() {
return _api.get('api/mobile/examinations/${widget.id}/expertise');
}
void _reload() {
setState(() => _future = _load());
}
void _prepareFields(Map<String, dynamic> data) {
final dlp = data['dlp']?.toString() ?? '';
if (_currentDlp == dlp && _textControllers.isNotEmpty) {
return;
}
_currentDlp = dlp;
for (final controller in _textControllers.values) {
controller.dispose();
}
_textControllers.clear();
_selectValues.clear();
_multiValues.clear();
_staffValues.clear();
final components = asMap(data['components']);
final item = asMap(data['item']);
final user = asMap(data['user']);
_staffValues['analis'] = (item['analis'] ?? user['id'] ?? '0').toString();
_staffValues['ppds3'] = (item['ppds3'] ?? user['id'] ?? '0').toString();
_staffValues['dokter'] = (item['dokter_id'] ?? user['id'] ?? '0')
.toString();
_criticalValue = data['is_critical'] == true;
for (final rawField in asList(data['fields'])) {
final field = asMap(rawField);
final name = field['name']?.toString() ?? '';
final type = field['type']?.toString() ?? 'text';
final value = components[name];
if (type == 'select') {
_selectValues[name] = value?.toString() ?? '';
} else if (type == 'multiselect') {
_multiValues[name] = value is List
? value.map((item) => item.toString()).toSet()
: <String>{};
} else {
_textControllers[name] = TextEditingController(
text: value?.toString() ?? '',
);
}
}
}
Future<void> _setTemplate(String dlp) async {
setState(() => _saving = true);
try {
await _api.post(
'api/mobile/examinations/${widget.id}/expertise/template',
{'dlp': dlp},
);
if (!mounted) return;
_showMessage('Template $dlp dipilih.');
setState(() => _future = _load());
} on ApiException catch (error) {
_showMessage(error.message);
} catch (_) {
_showMessage('Template tidak bisa dipilih.');
} finally {
if (mounted) {
setState(() => _saving = false);
}
}
}
Future<void> _save(String action) async {
setState(() => _saving = true);
final fields = <String, dynamic>{};
for (final entry in _textControllers.entries) {
fields[entry.key] = entry.value.text.trim();
}
for (final entry in _selectValues.entries) {
fields[entry.key] = entry.value;
}
for (final entry in _multiValues.entries) {
fields[entry.key] = entry.value.toList();
}
try {
final data = await _api
.post('api/mobile/examinations/${widget.id}/expertise', {
'dlp': _currentDlp,
'action': action,
'fields': fields,
'keterangan': fields['keterangan']?.toString() ?? '',
'analis': _staffValues['analis'] ?? '0',
'ppds3': _staffValues['ppds3'] ?? '0',
'dokter': _staffValues['dokter'] ?? '0',
'nilai_kritis': _criticalValue ? '1' : '0',
});
if (!mounted) return;
_showMessage(data['message']?.toString() ?? 'Expertise tersimpan.');
setState(() => _future = _load());
} on ApiException catch (error) {
_showMessage(error.message);
} catch (_) {
_showMessage('Expertise tidak bisa disimpan.');
} finally {
if (mounted) {
setState(() => _saving = false);
}
}
}
void _showMessage(String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Expertise'),
actions: [
IconButton(
onPressed: _saving ? null : _reload,
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
],
),
body: FutureBuilder<Map<String, dynamic>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const LoadingScreen();
}
if (snapshot.hasError) {
return ErrorView(
message: _message(snapshot.error),
onRetry: _reload,
);
}
final data = snapshot.data!;
final item = asMap(data['item']);
final user = asMap(data['user']);
_prepareFields(data);
final isSupervisor = _isSupervisor(user['previlage']?.toString());
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item['nmpasien']?.toString() ?? '-',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w800),
),
const SizedBox(height: 6),
Text(
'${item['nofoto'] ?? '-'} / RM ${item['noregister'] ?? '-'}',
),
Text(item['reques']?.toString() ?? '-'),
const SizedBox(height: 8),
Row(
children: [
StatusPill(
status: item['status']?.toString() ?? 'NEW',
),
const SizedBox(width: 8),
if (_currentDlp.isNotEmpty)
BadgeLabel(
text: _currentDlp,
color: const Color(0xFF0F766E),
),
],
),
],
),
),
),
const SizedBox(height: 14),
if (_currentDlp.isEmpty)
TemplatePicker(
templates: asList(data['templates']),
saving: _saving,
onUse: _setTemplate,
)
else
_currentDlp == 'CCI'
? CciExpertiseWizard(
item: item,
user: user,
staffOptions: asMap(data['staff_options']),
optionSets: asMap(data['option_sets']),
textControllers: _textControllers,
selectValues: _selectValues,
staffValues: _staffValues,
criticalValue: _criticalValue,
isSupervisor: isSupervisor,
saving: _saving,
onCriticalChanged: (value) =>
setState(() => _criticalValue = value),
onChanged: () => setState(() {}),
onSave: _save,
)
: _currentDlp == 'Kultur'
? KulturExpertiseWizard(
item: item,
user: user,
staffOptions: asMap(data['staff_options']),
optionSets: asMap(data['option_sets']),
textControllers: _textControllers,
selectValues: _selectValues,
multiValues: _multiValues,
staffValues: _staffValues,
criticalValue: _criticalValue,
isSupervisor: isSupervisor,
saving: _saving,
onCriticalChanged: (value) =>
setState(() => _criticalValue = value),
onChanged: () => setState(() {}),
onSave: _save,
)
: _currentDlp == 'Pewarna Langsung'
? PewarnaanLangsungExpertiseWizard(
item: item,
user: user,
staffOptions: asMap(data['staff_options']),
optionSets: asMap(data['option_sets']),
textControllers: _textControllers,
selectValues: _selectValues,
multiValues: _multiValues,
staffValues: _staffValues,
criticalValue: _criticalValue,
isSupervisor: isSupervisor,
saving: _saving,
onCriticalChanged: (value) =>
setState(() => _criticalValue = value),
onChanged: () => setState(() {}),
onSave: _save,
)
: _currentDlp == 'Viral Load'
? ViralLoadExpertiseWizard(
item: item,
user: user,
staffOptions: asMap(data['staff_options']),
optionSets: asMap(data['option_sets']),
textControllers: _textControllers,
selectValues: _selectValues,
staffValues: _staffValues,
criticalValue: _criticalValue,
isSupervisor: isSupervisor,
saving: _saving,
onCriticalChanged: (value) =>
setState(() => _criticalValue = value),
onChanged: () => setState(() {}),
onSave: _save,
)
: _currentDlp == 'IgM IgG Leptospira'
? LeptospiraExpertiseWizard(
item: item,
user: user,
staffOptions: asMap(data['staff_options']),
textControllers: _textControllers,
selectValues: _selectValues,
staffValues: _staffValues,
criticalValue: _criticalValue,
isSupervisor: isSupervisor,
saving: _saving,
onCriticalChanged: (value) =>
setState(() => _criticalValue = value),
onChanged: () => setState(() {}),
onSave: _save,
)
: _currentDlp == 'PCR COVID'
? CovidExpertiseWizard(
item: item,
user: user,
staffOptions: asMap(data['staff_options']),
textControllers: _textControllers,
selectValues: _selectValues,
staffValues: _staffValues,
criticalValue: _criticalValue,
isSupervisor: isSupervisor,
saving: _saving,
onCriticalChanged: (value) =>
setState(() => _criticalValue = value),
onChanged: () => setState(() {}),
onSave: _save,
)
: _currentDlp == 'TBC'
? TbcExpertiseWizard(
item: item,
user: user,
staffOptions: asMap(data['staff_options']),
textControllers: _textControllers,
selectValues: _selectValues,
multiValues: _multiValues,
staffValues: _staffValues,
criticalValue: _criticalValue,
isSupervisor: isSupervisor,
saving: _saving,
onCriticalChanged: (value) =>
setState(() => _criticalValue = value),
onChanged: () => setState(() {}),
onSave: _save,
)
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
PatientStaffPanel(
item: item,
user: user,
staffOptions: asMap(data['staff_options']),
staffValues: _staffValues,
onChanged: () => setState(() {}),
),
const SizedBox(height: 12),
ExpertiseForm(
dlp: _currentDlp,
fields: asList(data['fields']),
textControllers: _textControllers,
selectValues: _selectValues,
multiValues: _multiValues,
onChanged: () => setState(() {}),
),
const SizedBox(height: 12),
ExpertiseActions(
isSupervisor: isSupervisor,
saving: _saving,
onSave: _save,
),
],
),
],
);
},
),
);
}
}
@@ -0,0 +1,688 @@
part of '../../app/app.dart';
class KulturExpertiseWizard extends StatefulWidget {
const KulturExpertiseWizard({
super.key,
required this.item,
required this.user,
required this.staffOptions,
required this.optionSets,
required this.textControllers,
required this.selectValues,
required this.multiValues,
required this.staffValues,
required this.criticalValue,
required this.isSupervisor,
required this.saving,
required this.onCriticalChanged,
required this.onChanged,
required this.onSave,
});
final Map<String, dynamic> item;
final Map<String, dynamic> user;
final Map<String, dynamic> staffOptions;
final Map<String, dynamic> optionSets;
final Map<String, TextEditingController> textControllers;
final Map<String, String> selectValues;
final Map<String, Set<String>> multiValues;
final Map<String, String> staffValues;
final bool criticalValue;
final bool isSupervisor;
final bool saving;
final ValueChanged<bool> onCriticalChanged;
final VoidCallback onChanged;
final ValueChanged<String> onSave;
@override
State<KulturExpertiseWizard> createState() => _KulturExpertiseWizardState();
}
class _KulturExpertiseWizardState extends State<KulturExpertiseWizard> {
int _step = 0;
void _next() {
setState(() => _step = (_step + 1).clamp(0, 5));
}
void _previous() {
setState(() => _step = (_step - 1).clamp(0, 5));
}
@override
Widget build(BuildContext context) {
return Stepper(
currentStep: _step,
onStepTapped: (step) => setState(() => _step = step),
controlsBuilder: (context, details) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
OutlinedButton.icon(
onPressed: _step == 0 ? null : _previous,
icon: const Icon(Icons.chevron_left),
label: const Text('Previous'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _step == 5 ? null : _next,
icon: const Icon(Icons.chevron_right),
label: const Text('Next'),
),
],
),
);
},
steps: [
Step(
title: const Text('Pasien & Petugas'),
isActive: _step == 0,
content: PatientStaffPanel(
item: widget.item,
user: widget.user,
staffOptions: widget.staffOptions,
staffValues: widget.staffValues,
onChanged: widget.onChanged,
),
),
Step(
title: const Text('Sediaan Langsung'),
isActive: _step == 1,
content: _directSmear(),
),
Step(
title: const Text('Biakan Kultur'),
isActive: _step == 2,
content: _cultureGrowth(),
),
Step(
title: const Text('Tes Kepekaan Antibiotik'),
isActive: _step == 3,
content: _antibioticSensitivity(),
),
Step(
title: const Text('Data Alat'),
isActive: _step == 4,
content: _toolData(),
),
Step(
title: const Text('Expertise'),
isActive: _step == 5,
content: _finalExpertise(),
),
],
);
}
Widget _directSmear() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('A. Sediaan Langsung'),
_sectionTitle('1. Pewarna Gram', compact: true),
_select('id_selepitel', 'Sel Epitel', _options('jsonselepitel')),
_select('id_selradang', 'Sel Radang', _options('jsonselradang')),
_select(
'id_mikroorganisme',
'Mikroorganisme',
_options('jsonmikroorganisme'),
),
_multiSelect(
'id_mikroorganismeoptional',
'Bisa memilih lebih dari satu',
_options('jsonmikroorganismeoptional'),
),
_nugentScore(),
_sectionTitle('2. Pewarnaan Ziehl Neelsen', compact: true),
_select(
'id_pewarnaanziehlnielsen',
'Pagi',
_options('jsonpewarnaanziehlnielsen'),
),
_select(
'id_pewarnaanziehlnielsensewaktu',
'Sewaktu',
_options('jsonpewarnaanziehlnielsen'),
),
_select(
'id_pewarnaankoh',
'3. Pewarnaan KOH',
_options('jsonpewarnaankoh', _kohFallback),
),
_text('id_pewarnaankohoptional', 'Pewarnaan KOH Manual'),
_select(
'id_pewarnaanneisser',
'4. Pewarnaan Neisser',
_options('jsonpewarnaanneisser'),
),
_select(
'id_pewarnaannegatif',
'5. Pewarnaan Negatif',
_options('jsonpewarnaannegatif'),
),
_select(
'id_pewarnaanspora',
'6. Pewarnaan Spora',
_options('jsonpewarnaanspora'),
),
_select(
'id_pewarnaangiesma',
'7. Pewarnaan Giemsa',
_options('jsonpewarnaangiemsa', _giemsaFallback),
),
_text('id_pewarnaangiesmaoptional', 'Pewarnaan Giemsa Manual'),
],
);
}
Widget _nugentScore() {
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Nugent Score', compact: true),
_select(
'id_jumlahlactobacillus',
'Lactobacillus - Jumlah Per Lapang',
_options('jsonjumlahlactobacillus'),
),
_select('id_lactobacillus', 'Lactobacillus - Skor', _scoreOptions),
_select(
'id_jumlahgardnerella',
'Gardnerella - Jumlah Per Lapang',
_options('jsonjumlahgardnerella'),
),
_select('id_gardnerella', 'Gardnerella - Skor', _scoreOptions),
_select(
'id_jumlahmobiluncus',
'Mobiluncus - Jumlah Per Lapang',
_options('jsonjumlahmobiluncus'),
),
_select('id_mobiluncus', 'Mobiluncus - Skor', _scoreOptions),
],
),
),
);
}
Widget _cultureGrowth() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('B. Biakan Kultur'),
const BadgeLabel(
text: 'Khusus Kultur Darah dan Cairan Steril',
color: Color(0xFFDC2626),
),
const SizedBox(height: 12),
_text('bd_result', 'BD Result'),
_text('bd_result_date', 'BD Result Date'),
_sectionTitle('Pertumbuhan Koloni (Kultur Primer)', compact: true),
_kirbyBauer(),
_select(
'id_biakankultur',
'Biakan Kultur (Aerob / Anaerob)',
_options('jsonbiakankultur'),
),
],
);
}
Widget _kirbyBauer() {
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Kirby Bauer', compact: true),
for (final antibiotic in _kirbyAntibiotics)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Expanded(
child: _select(
antibiotic.zoneField,
antibiotic.label,
_kirbyZoneOptions,
dense: true,
),
),
const SizedBox(width: 8),
SizedBox(
width: 120,
child: _select(
antibiotic.sirField,
'SIR',
_antibioticSirOptions,
dense: true,
),
),
],
),
),
],
),
),
);
}
Widget _antibioticSensitivity() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('C. Tes Kepekaan Antibiotik'),
Text(
'S: Sensitif, I: Intermediate, R: Resisten',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
_mediaStatusSummary(),
const SizedBox(height: 12),
for (final media in _cultureMedia)
Card(
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(media.label, compact: true),
_select(
media.statusField,
'Status Pertumbuhan',
_growthOptions,
),
_text(media.noteField, 'Catatan'),
],
),
),
),
],
);
}
Widget _mediaStatusSummary() {
return Wrap(
spacing: 8,
runSpacing: 8,
children: const [
BadgeLabel(text: 'Media BAP', color: Color(0xFF2563EB)),
BadgeLabel(text: 'Media CAP', color: Color(0xFFDC2626)),
BadgeLabel(text: 'Mc Conkey', color: Color(0xFF0891B2)),
BadgeLabel(text: 'Media Jamur', color: Color(0xFFD97706)),
],
);
}
Widget _toolData() {
return Column(
children: [
_toolSection(
title: 'Vitek',
bacteriaField: 'bakteri',
sirField: 'bakterisir',
colonyField: 'id_bakterihitungkol',
colonyTextField: 'id_bakterihitungkolteks',
printField: 'id_baktericetak',
),
_toolSection(
title: 'MALDITOF',
bacteriaField: 'id_bakteri01',
antibioticField: 'id_antibiotikmanual1',
sirField: 'id_bakterisir01',
colonyField: 'id_bakterihitungkol01',
colonyTextField: 'id_bakterihitungkolteks01',
printField: 'id_bakteri01cetak',
),
_toolSection(
title: 'Bakteri Manual I',
bacteriaField: 'id_bakteri02',
antibioticField: 'id_antibiotikmanual2',
sirField: 'id_bakterisir02',
colonyField: 'id_bakterihitungkol02',
colonyTextField: 'id_bakterihitungkolteks02',
printField: 'id_bakteri02cetak',
),
],
);
}
Widget _finalExpertise() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_text('keterangan', 'Expertise', minLines: 8, maxLines: 12),
CheckboxListTile(
value: widget.criticalValue,
onChanged: (value) => widget.onCriticalChanged(value ?? false),
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: const Text('Nilai Kritis'),
),
FilledButton.icon(
onPressed: widget.saving ? null : () => widget.onSave('Draft'),
icon: widget.saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined),
label: const Text('Save as Draft'),
),
if (widget.isSupervisor) ...[
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('preliminary'),
icon: const Icon(Icons.done_all_outlined),
label: const Text('Save Preliminary result'),
),
OutlinedButton.icon(
onPressed: widget.saving ? null : () => widget.onSave('verifikasi'),
icon: const Icon(Icons.verified_outlined),
label: const Text('Save Final Result'),
),
] else ...[
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('Permohonan Verifikasi'),
icon: const Icon(Icons.send_outlined),
label: const Text('Save and Send To SPV'),
),
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('Permohonan Verifikasi Preliminary'),
icon: const Icon(Icons.outgoing_mail),
label: const Text('Kirim SPV Preliminary'),
),
],
],
);
}
List<String> _options(
String key, [
List<String> fallback = const ['Pilih Salah Satu'],
]) {
final values = asList(
widget.optionSets[key],
).map((item) => item.toString()).where((item) => item.isNotEmpty).toList();
return values.isEmpty ? fallback : values;
}
Widget _sectionTitle(String title, {bool compact = false}) {
return Padding(
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800,
color: const Color(0xFF0F766E),
),
),
);
}
Widget _toolSection({
required String title,
required String bacteriaField,
String? antibioticField,
required String sirField,
required String colonyField,
required String colonyTextField,
required String printField,
}) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(title, compact: true),
_text(bacteriaField, 'Bakteri'),
if (antibioticField != null)
_text(antibioticField, 'Set Antibiotik'),
_select(sirField, 'SIR', _sirOptions),
_select(colonyField, 'Hitung Koloni', _colonyOptions),
_text(colonyTextField, 'Hitung Koloni Lainnya'),
_select(printField, 'Cetak', const ['YA', 'TIDAK']),
],
),
),
);
}
Widget _select(
String name,
String label,
List<String> options, {
bool dense = false,
}) {
final current = widget.selectValues[name];
final effectiveOptions = [
if (current != null && current.isNotEmpty && !options.contains(current))
current,
...options,
];
return Padding(
padding: EdgeInsets.only(bottom: dense ? 0 : 12),
child: DropdownButtonFormField<String>(
initialValue:
current != null &&
current.isNotEmpty &&
effectiveOptions.contains(current)
? current
: null,
isExpanded: true,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
isDense: dense,
),
items: effectiveOptions
.map(
(option) => DropdownMenuItem(value: option, child: Text(option)),
)
.toList(),
onChanged: (value) {
widget.selectValues[name] = value ?? '';
widget.onChanged();
},
),
);
}
Widget _multiSelect(String name, String label, List<String> options) {
final selected = widget.multiValues[name] ?? <String>{};
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
child: Wrap(
spacing: 8,
runSpacing: 4,
children: options
.map(
(option) => FilterChip(
label: Text(option),
selected: selected.contains(option),
onSelected: (checked) {
if (checked) {
selected.add(option);
} else {
selected.remove(option);
}
widget.multiValues[name] = selected;
widget.onChanged();
},
),
)
.toList(),
),
),
);
}
Widget _text(
String name,
String label, {
int minLines = 1,
int maxLines = 1,
}) {
final controller = widget.textControllers[name] ??= TextEditingController();
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
controller: controller,
minLines: minLines,
maxLines: maxLines,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
),
);
}
}
class _KirbyAntibiotic {
const _KirbyAntibiotic(this.label, this.zoneField, this.sirField);
final String label;
final String zoneField;
final String sirField;
}
class _CultureMedia {
const _CultureMedia(this.label, this.statusField, this.noteField);
final String label;
final String statusField;
final String noteField;
}
const List<String> _scoreOptions = ['0', '1', '2', '3', '4'];
const List<String> _antibioticSirOptions = ['S', 'I', 'R'];
const List<String> _growthOptions = [
'Ada Pertumbuhan',
'Tidak Ada Pertumbuhan',
'Pertumbuhan Primer',
];
const List<String> _kirbyZoneOptions = [
'Tidak dilakukan',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'10',
'11',
'12',
'13',
'14',
'15',
'16',
'17',
'18',
'19',
'20',
'21',
'22',
'23',
'24',
'25',
'26',
'27',
'28',
'29',
'30',
'31',
'32',
'33',
'34',
'35',
'36',
'37',
'38',
'39',
'40',
'41',
'42',
];
const List<_KirbyAntibiotic> _kirbyAntibiotics = [
_KirbyAntibiotic('AMC', 'id_kbamc', 'id_sirkbamc'),
_KirbyAntibiotic('AK', 'id_kbak', 'id_sirkbak'),
_KirbyAntibiotic('FOS', 'id_kbfos', 'id_sirkbfos'),
_KirbyAntibiotic('SCF', 'id_kbscf', 'id_sirkbscf'),
];
const List<_CultureMedia> _cultureMedia = [
_CultureMedia('Media BAP', 'media_bap_status', 'media_bap_note'),
_CultureMedia('Media CAP', 'media_cap_status', 'media_cap_note'),
_CultureMedia(
'Media Mc Conkey',
'media_mcconkey_status',
'media_mcconkey_note',
),
_CultureMedia('Kultur Jamur R1', 'media_sdar1_status', 'media_sdar1_note'),
_CultureMedia('Kultur Jamur R2', 'media_sdar2_status', 'media_sdar2_note'),
_CultureMedia('Kultur Jamur I1', 'media_sdai1_status', 'media_sdai1_note'),
_CultureMedia('Kultur Jamur I2', 'media_sdai2_status', 'media_sdai2_note'),
_CultureMedia(
'Media Selektif Lainnya',
'media_sellainnya_status',
'media_sellainnya_note',
),
_CultureMedia(
'Pemeriksaan Tambahan Lainnya',
'media_tamlainnya_status',
'media_tamlainnya_note',
),
];
const List<String> _kohFallback = [
'Ditemukan morfologi Hifa',
'Ditemukan morfologi Budding Cell',
'Ditemukan morfologi Conidia',
'Ditemukan morfologi Hifa dan Budding Cell',
'Ditemukan morfologi Hifa dan Conidia',
'Ditemukan morfologi Budding Cell dan Conidia',
'Tidak ditemukan morfologi Budding Cell, Hifa dan Conidia',
'Tidak ditemukan morfologi Budding Cell dan Hifa',
'lainnya',
];
const List<String> _giemsaFallback = [
'Ditemukan morfologi Inclusion Bodies dan Reticulate Bodies',
'Tidak ditemukan morfologi Inclusion Bodies dan Reticulate Bodies',
'Ditemukan morfologi kista tropozoid',
'Tidak ditemukan morfologi kista tropozoid',
'lainnya',
];
@@ -0,0 +1,361 @@
part of '../../app/app.dart';
class LeptospiraExpertiseWizard extends StatefulWidget {
const LeptospiraExpertiseWizard({
super.key,
required this.item,
required this.user,
required this.staffOptions,
required this.textControllers,
required this.selectValues,
required this.staffValues,
required this.criticalValue,
required this.isSupervisor,
required this.saving,
required this.onCriticalChanged,
required this.onChanged,
required this.onSave,
});
final Map<String, dynamic> item;
final Map<String, dynamic> user;
final Map<String, dynamic> staffOptions;
final Map<String, TextEditingController> textControllers;
final Map<String, String> selectValues;
final Map<String, String> staffValues;
final bool criticalValue;
final bool isSupervisor;
final bool saving;
final ValueChanged<bool> onCriticalChanged;
final VoidCallback onChanged;
final ValueChanged<String> onSave;
@override
State<LeptospiraExpertiseWizard> createState() =>
_LeptospiraExpertiseWizardState();
}
class _LeptospiraExpertiseWizardState extends State<LeptospiraExpertiseWizard> {
int _step = 0;
void _next() {
setState(() => _step = (_step + 1).clamp(0, 4));
}
void _previous() {
setState(() => _step = (_step - 1).clamp(0, 4));
}
@override
Widget build(BuildContext context) {
return Stepper(
currentStep: _step,
onStepTapped: (step) => setState(() => _step = step),
controlsBuilder: (context, details) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
OutlinedButton.icon(
onPressed: _step == 0 ? null : _previous,
icon: const Icon(Icons.chevron_left),
label: const Text('Previous'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _step == 4 ? null : _next,
icon: const Icon(Icons.chevron_right),
label: const Text('Next'),
),
],
),
);
},
steps: [
Step(
title: const Text('Pasien & Petugas'),
isActive: _step == 0,
content: PatientStaffPanel(
item: widget.item,
user: widget.user,
staffOptions: widget.staffOptions,
staffValues: widget.staffValues,
onChanged: widget.onChanged,
),
),
Step(
title: const Text('Parameter'),
isActive: _step == 1,
content: _parameters(),
),
Step(
title: const Text('Tes Kepekaan Antibiotik'),
isActive: _step == 2,
content: _antibioticSensitivity(),
),
Step(
title: const Text('Data Alat'),
isActive: _step == 3,
content: _toolData(),
),
Step(
title: const Text('Expertise'),
isActive: _step == 4,
content: _finalExpertise(),
),
],
);
}
Widget _parameters() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('IgM IgG Leptospira'),
_select('igg_parameter', 'IgG Leptospira', const [
'Positif',
'Negatif',
]),
_select('igm_parameter', 'IgM Leptospira', const [
'Positif',
'Negatif',
]),
_text('iggigm_interpretasi', 'Interpretasi', minLines: 6, maxLines: 9),
],
);
}
Widget _antibioticSensitivity() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Tes Kepekaan Antibiotik'),
Text(
'S: Sensitif, I: Intermediate, R: Resisten',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: const [
BadgeLabel(text: 'Media BAP', color: Color(0xFF2563EB)),
BadgeLabel(text: 'Media CAP', color: Color(0xFFDC2626)),
BadgeLabel(text: 'Mc Conkey', color: Color(0xFF0891B2)),
BadgeLabel(text: 'Media Jamur', color: Color(0xFFD97706)),
],
),
const SizedBox(height: 12),
for (final media in _cultureMedia)
Card(
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(media.label, compact: true),
_select(
media.statusField,
'Status Pertumbuhan',
_growthOptions,
),
_text(media.noteField, 'Catatan'),
],
),
),
),
],
);
}
Widget _toolData() {
return Column(
children: [
_toolSection(
title: 'Vitek',
bacteriaField: 'bakteri',
sirField: 'bakterisir',
colonyField: 'id_bakterihitungkol',
colonyTextField: 'id_bakterihitungkolteks',
printField: 'id_baktericetak',
),
_toolSection(
title: 'MALDITOF',
bacteriaField: 'id_bakteri01',
antibioticField: 'id_antibiotikmanual1',
sirField: 'id_bakterisir01',
colonyField: 'id_bakterihitungkol01',
colonyTextField: 'id_bakterihitungkolteks01',
printField: 'id_bakteri01cetak',
),
_toolSection(
title: 'Bakteri Manual I',
bacteriaField: 'id_bakteri02',
antibioticField: 'id_antibiotikmanual2',
sirField: 'id_bakterisir02',
colonyField: 'id_bakterihitungkol02',
colonyTextField: 'id_bakterihitungkolteks02',
printField: 'id_bakteri02cetak',
),
],
);
}
Widget _finalExpertise() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_text('keterangan', 'Expertise', minLines: 8, maxLines: 12),
CheckboxListTile(
value: widget.criticalValue,
onChanged: (value) => widget.onCriticalChanged(value ?? false),
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: const Text('Nilai Kritis'),
),
FilledButton.icon(
onPressed: widget.saving ? null : () => widget.onSave('Draft'),
icon: widget.saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined),
label: const Text('Save as Draft'),
),
if (widget.isSupervisor) ...[
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('preliminary'),
icon: const Icon(Icons.done_all_outlined),
label: const Text('Save Preliminary result'),
),
OutlinedButton.icon(
onPressed: widget.saving ? null : () => widget.onSave('verifikasi'),
icon: const Icon(Icons.verified_outlined),
label: const Text('Save Final Result'),
),
] else ...[
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('Permohonan Verifikasi'),
icon: const Icon(Icons.send_outlined),
label: const Text('Save and Send To SPV'),
),
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('Permohonan Verifikasi Preliminary'),
icon: const Icon(Icons.outgoing_mail),
label: const Text('Kirim SPV Preliminary'),
),
],
],
);
}
Widget _sectionTitle(String title, {bool compact = false}) {
return Padding(
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800,
color: const Color(0xFF0F766E),
),
),
);
}
Widget _toolSection({
required String title,
required String bacteriaField,
String? antibioticField,
required String sirField,
required String colonyField,
required String colonyTextField,
required String printField,
}) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(title, compact: true),
_text(bacteriaField, 'Bakteri'),
if (antibioticField != null)
_text(antibioticField, 'Set Antibiotik'),
_select(sirField, 'SIR', _sirOptions),
_select(colonyField, 'Hitung Koloni', _colonyOptions),
_text(colonyTextField, 'Hitung Koloni Lainnya'),
_select(printField, 'Cetak', const ['YA', 'TIDAK']),
],
),
),
);
}
Widget _select(String name, String label, List<String> options) {
final current = widget.selectValues[name];
final effectiveOptions = [
if (current != null && current.isNotEmpty && !options.contains(current))
current,
...options,
];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: DropdownButtonFormField<String>(
initialValue:
current != null &&
current.isNotEmpty &&
effectiveOptions.contains(current)
? current
: null,
isExpanded: true,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
items: effectiveOptions
.map(
(option) => DropdownMenuItem(value: option, child: Text(option)),
)
.toList(),
onChanged: (value) {
widget.selectValues[name] = value ?? '';
widget.onChanged();
},
),
);
}
Widget _text(
String name,
String label, {
int minLines = 1,
int maxLines = 1,
}) {
final controller = widget.textControllers[name] ??= TextEditingController();
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
controller: controller,
minLines: minLines,
maxLines: maxLines,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
),
);
}
}
@@ -0,0 +1,493 @@
part of '../../app/app.dart';
class PewarnaanLangsungExpertiseWizard extends StatefulWidget {
const PewarnaanLangsungExpertiseWizard({
super.key,
required this.item,
required this.user,
required this.staffOptions,
required this.optionSets,
required this.textControllers,
required this.selectValues,
required this.multiValues,
required this.staffValues,
required this.criticalValue,
required this.isSupervisor,
required this.saving,
required this.onCriticalChanged,
required this.onChanged,
required this.onSave,
});
final Map<String, dynamic> item;
final Map<String, dynamic> user;
final Map<String, dynamic> staffOptions;
final Map<String, dynamic> optionSets;
final Map<String, TextEditingController> textControllers;
final Map<String, String> selectValues;
final Map<String, Set<String>> multiValues;
final Map<String, String> staffValues;
final bool criticalValue;
final bool isSupervisor;
final bool saving;
final ValueChanged<bool> onCriticalChanged;
final VoidCallback onChanged;
final ValueChanged<String> onSave;
@override
State<PewarnaanLangsungExpertiseWizard> createState() =>
_PewarnaanLangsungExpertiseWizardState();
}
class _PewarnaanLangsungExpertiseWizardState
extends State<PewarnaanLangsungExpertiseWizard> {
int _step = 0;
void _next() {
setState(() => _step = (_step + 1).clamp(0, 4));
}
void _previous() {
setState(() => _step = (_step - 1).clamp(0, 4));
}
@override
Widget build(BuildContext context) {
return Stepper(
currentStep: _step,
onStepTapped: (step) => setState(() => _step = step),
controlsBuilder: (context, details) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
OutlinedButton.icon(
onPressed: _step == 0 ? null : _previous,
icon: const Icon(Icons.chevron_left),
label: const Text('Previous'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _step == 4 ? null : _next,
icon: const Icon(Icons.chevron_right),
label: const Text('Next'),
),
],
),
);
},
steps: [
Step(
title: const Text('Pasien & Petugas'),
isActive: _step == 0,
content: PatientStaffPanel(
item: widget.item,
user: widget.user,
staffOptions: widget.staffOptions,
staffValues: widget.staffValues,
onChanged: widget.onChanged,
),
),
Step(
title: const Text('Sediaan Langsung'),
isActive: _step == 1,
content: _directSmear(),
),
Step(
title: const Text('Tes Kepekaan Antibiotik'),
isActive: _step == 2,
content: _antibioticSensitivity(),
),
Step(
title: const Text('Data Alat'),
isActive: _step == 3,
content: _toolData(),
),
Step(
title: const Text('Expertise'),
isActive: _step == 4,
content: _finalExpertise(),
),
],
);
}
Widget _directSmear() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('A. Sediaan Langsung'),
_sectionTitle('1. Pewarnaan Gram', compact: true),
_select('lsg_selepitel', 'Sel Epitel', _options('jsonselepitel')),
_select('lsg_selradang', 'Sel Radang', _options('jsonselradang')),
_select(
'lsg_mikroorganisme',
'Mikroorganisme',
_options('jsonmikroorganisme'),
),
_multiSelect(
'lsg_mikroorganismeoptional',
'Bisa memilih lebih dari satu',
_options('jsonmikroorganismeoptional'),
),
_sectionTitle('2. Pewarnaan Ziehl Neelsen', compact: true),
_select(
'lsg_pewarnaanziehlnielsen',
'Pagi',
_options('jsonpewarnaanziehlnielsen'),
),
_select(
'lsg_pewarnaanziehlnielsensewaktu',
'Sewaktu',
_options('jsonpewarnaanziehlnielsen'),
),
_select(
'lsg_pewarnaankoh',
'3. Pewarnaan KOH',
_options('jsonpewarnaankoh', _kohFallback),
),
_text('lsg_pewarnaankohoptional', 'Pewarnaan KOH Manual'),
_select(
'lsg_pewarnaanneisser',
'4. Pewarnaan Neisser',
_options('jsonpewarnaanneisser'),
),
_select(
'lsg_pewarnaannegatif',
'5. Pewarnaan Negatif',
_options('jsonpewarnaannegatif'),
),
_select(
'lsg_pewarnaangiesma',
'6. Pewarnaan Giemsa',
_options('jsonpewarnaangiemsa', _giemsaFallback),
),
_text('lsg_pewarnaangiesmaoptional', 'Pewarnaan Giemsa Manual'),
_select(
'lsg_pewarnaanspora',
'7. Pewarnaan Spora',
_options('jsonpewarnaanspora'),
),
_text(
'lsg_pewarnaanlain',
'8. Pewarnaan Lain-Lain',
minLines: 5,
maxLines: 8,
),
_nugentScore(),
],
);
}
Widget _nugentScore() {
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Nugent Score', compact: true),
_select(
'lsg_jumlahlactobacillus',
'Lactobacillus - Jumlah Per Lapang',
_options('jsonjumlahlactobacillus'),
),
_select('lsg_lactobacillus', 'Lactobacillus - Skor', _scoreOptions),
_select(
'lsg_jumlahgardnerella',
'Gardnerella - Jumlah Per Lapang',
_options('jsonjumlahgardnerella'),
),
_select('lsg_gardnerella', 'Gardnerella - Skor', _scoreOptions),
_select(
'lsg_jumlahmobiluncus',
'Mobiluncus - Jumlah Per Lapang',
_options('jsonjumlahmobiluncus'),
),
_select('lsg_mobiluncus', 'Mobiluncus - Skor', _scoreOptions),
],
),
),
);
}
Widget _antibioticSensitivity() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Tes Kepekaan Antibiotik'),
Text(
'S: Sensitif, I: Intermediate, R: Resisten',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: const [
BadgeLabel(text: 'Media BAP', color: Color(0xFF2563EB)),
BadgeLabel(text: 'Media CAP', color: Color(0xFFDC2626)),
BadgeLabel(text: 'Mc Conkey', color: Color(0xFF0891B2)),
BadgeLabel(text: 'Media Jamur', color: Color(0xFFD97706)),
],
),
const SizedBox(height: 12),
for (final media in _cultureMedia)
Card(
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(media.label, compact: true),
_select(
media.statusField,
'Status Pertumbuhan',
_growthOptions,
),
_text(media.noteField, 'Catatan'),
],
),
),
),
],
);
}
Widget _toolData() {
return Column(
children: [
_toolSection(
title: 'Vitek',
bacteriaField: 'bakteri',
sirField: 'bakterisir',
colonyField: 'id_bakterihitungkol',
colonyTextField: 'id_bakterihitungkolteks',
printField: 'id_baktericetak',
),
_toolSection(
title: 'MALDITOF',
bacteriaField: 'id_bakteri01',
antibioticField: 'id_antibiotikmanual1',
sirField: 'id_bakterisir01',
colonyField: 'id_bakterihitungkol01',
colonyTextField: 'id_bakterihitungkolteks01',
printField: 'id_bakteri01cetak',
),
_toolSection(
title: 'Bakteri Manual I',
bacteriaField: 'id_bakteri02',
antibioticField: 'id_antibiotikmanual2',
sirField: 'id_bakterisir02',
colonyField: 'id_bakterihitungkol02',
colonyTextField: 'id_bakterihitungkolteks02',
printField: 'id_bakteri02cetak',
),
],
);
}
Widget _finalExpertise() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_text('keterangan', 'Expertise', minLines: 8, maxLines: 12),
CheckboxListTile(
value: widget.criticalValue,
onChanged: (value) => widget.onCriticalChanged(value ?? false),
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: const Text('Nilai Kritis'),
),
FilledButton.icon(
onPressed: widget.saving ? null : () => widget.onSave('Draft'),
icon: widget.saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined),
label: const Text('Save as Draft'),
),
if (widget.isSupervisor) ...[
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('preliminary'),
icon: const Icon(Icons.done_all_outlined),
label: const Text('Save Preliminary result'),
),
OutlinedButton.icon(
onPressed: widget.saving ? null : () => widget.onSave('verifikasi'),
icon: const Icon(Icons.verified_outlined),
label: const Text('Save Final Result'),
),
] else ...[
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('Permohonan Verifikasi'),
icon: const Icon(Icons.send_outlined),
label: const Text('Save and Send To SPV'),
),
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('Permohonan Verifikasi Preliminary'),
icon: const Icon(Icons.outgoing_mail),
label: const Text('Kirim SPV Preliminary'),
),
],
],
);
}
List<String> _options(
String key, [
List<String> fallback = const ['Pilih Salah Satu'],
]) {
final values = asList(
widget.optionSets[key],
).map((item) => item.toString()).where((item) => item.isNotEmpty).toList();
return values.isEmpty ? fallback : values;
}
Widget _sectionTitle(String title, {bool compact = false}) {
return Padding(
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800,
color: const Color(0xFF0F766E),
),
),
);
}
Widget _toolSection({
required String title,
required String bacteriaField,
String? antibioticField,
required String sirField,
required String colonyField,
required String colonyTextField,
required String printField,
}) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(title, compact: true),
_text(bacteriaField, 'Bakteri'),
if (antibioticField != null)
_text(antibioticField, 'Set Antibiotik'),
_select(sirField, 'SIR', _sirOptions),
_select(colonyField, 'Hitung Koloni', _colonyOptions),
_text(colonyTextField, 'Hitung Koloni Lainnya'),
_select(printField, 'Cetak', const ['YA', 'TIDAK']),
],
),
),
);
}
Widget _select(String name, String label, List<String> options) {
final current = widget.selectValues[name];
final effectiveOptions = [
if (current != null && current.isNotEmpty && !options.contains(current))
current,
...options,
];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: DropdownButtonFormField<String>(
initialValue:
current != null &&
current.isNotEmpty &&
effectiveOptions.contains(current)
? current
: null,
isExpanded: true,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
items: effectiveOptions
.map(
(option) => DropdownMenuItem(value: option, child: Text(option)),
)
.toList(),
onChanged: (value) {
widget.selectValues[name] = value ?? '';
widget.onChanged();
},
),
);
}
Widget _multiSelect(String name, String label, List<String> options) {
final selected = widget.multiValues[name] ?? <String>{};
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
child: Wrap(
spacing: 8,
runSpacing: 4,
children: options
.map(
(option) => FilterChip(
label: Text(option),
selected: selected.contains(option),
onSelected: (checked) {
if (checked) {
selected.add(option);
} else {
selected.remove(option);
}
widget.multiValues[name] = selected;
widget.onChanged();
},
),
)
.toList(),
),
),
);
}
Widget _text(
String name,
String label, {
int minLines = 1,
int maxLines = 1,
}) {
final controller = widget.textControllers[name] ??= TextEditingController();
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
controller: controller,
minLines: minLines,
maxLines: maxLines,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
),
);
}
}
@@ -0,0 +1,669 @@
part of '../../app/app.dart';
class TbcExpertiseWizard extends StatefulWidget {
const TbcExpertiseWizard({
super.key,
required this.item,
required this.user,
required this.staffOptions,
required this.textControllers,
required this.selectValues,
required this.multiValues,
required this.staffValues,
required this.criticalValue,
required this.isSupervisor,
required this.saving,
required this.onCriticalChanged,
required this.onChanged,
required this.onSave,
});
final Map<String, dynamic> item;
final Map<String, dynamic> user;
final Map<String, dynamic> staffOptions;
final Map<String, TextEditingController> textControllers;
final Map<String, String> selectValues;
final Map<String, Set<String>> multiValues;
final Map<String, String> staffValues;
final bool criticalValue;
final bool isSupervisor;
final bool saving;
final ValueChanged<bool> onCriticalChanged;
final VoidCallback onChanged;
final ValueChanged<String> onSave;
@override
State<TbcExpertiseWizard> createState() => _TbcExpertiseWizardState();
}
class _TbcExpertiseWizardState extends State<TbcExpertiseWizard> {
int _step = 0;
void _next() {
setState(() => _step = (_step + 1).clamp(0, 6));
}
void _previous() {
setState(() => _step = (_step - 1).clamp(0, 6));
}
@override
Widget build(BuildContext context) {
return Stepper(
currentStep: _step,
onStepTapped: (step) => setState(() => _step = step),
controlsBuilder: (context, details) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
OutlinedButton.icon(
onPressed: _step == 0 ? null : _previous,
icon: const Icon(Icons.chevron_left),
label: const Text('Previous'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _step == 6 ? null : _next,
icon: const Icon(Icons.chevron_right),
label: const Text('Next'),
),
],
),
);
},
steps: [
Step(
title: const Text('Pasien & Petugas'),
isActive: _step == 0,
content: PatientStaffPanel(
item: widget.item,
user: widget.user,
staffOptions: widget.staffOptions,
staffValues: widget.staffValues,
onChanged: widget.onChanged,
),
),
Step(
title: const Text('Riwayat & Identitas Nasional'),
isActive: _step == 1,
content: _historyAndIdentity(),
),
Step(
title: const Text('Jenis Pemeriksaan'),
isActive: _step == 2,
content: _testType(),
),
Step(
title: const Text('Hasil Pemeriksaan'),
isActive: _step == 3,
content: _examinationResultByType(),
),
Step(
title: const Text('Tes Kepekaan Antibiotik'),
isActive: _step == 4,
content: _antibioticSensitivity(),
),
Step(
title: const Text('Data Alat'),
isActive: _step == 5,
content: _toolData(),
),
Step(
title: const Text('Expertise'),
isActive: _step == 6,
content: _finalExpertise(),
),
],
);
}
Widget _historyAndIdentity() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Jenis Terduga / Pasien TBC'),
_multiSelect('jenis_pasien_tbc', 'Jenis Terduga / Pasien TBC', const [
'TBC SO',
'TBC RO',
'Anak',
'HIV',
'DM',
]),
_text('id_noidsediaan', 'No. Identitas Sediaan'),
_text(
'id_tanggalpengambilancontohuji',
'Tanggal Pengambilan Contoh Uji',
),
_text('id_tanggalpengirimancontohuji', 'Tanggal Pengiriman Contoh Uji'),
_select(
'id_lamariwayatpengobatantb',
'Lama Riwayat Pengobatan Terduga/Pasien TBC',
const [
'Riwayat Pengobatan >= 5 tahun',
'Riwayat Pengobatan < 5 tahun',
],
),
_select('id_lokasianatomi', 'Lokasi Anatomi', const [
'Paru',
'Ekstraparu',
]),
_text('id_ekstraparu', 'Ekstraparu'),
_select('id_alasanpemeriksaan', 'Alasan Pemeriksaan', const [
'Diagnosis TBC',
'Diagnosis Baseline TBC',
'Akhir Pengobatan',
]),
_text(
'id_bulankemajuantbfollow',
'Pemantauan kemajuan pengobatan (Bulan ke)',
),
_text('id_bulanpemeriksaanulang', 'Pemeriksaan ulang (Bulan ke)'),
_text(
'id_bulanpemeriksaansetelahselesai',
'Pemeriksaan setelah selesai pengobatan (Bulan ke)',
),
_text('id_noregfasyankes', 'No.Reg.TBC/TBC RO Fasyankes'),
_text('id_noregkota', 'No.Reg.TBC/TBC RO Kab/Kota'),
_select('id_contohuji', 'Contoh Uji', const ['Dahak', 'Lainnya']),
_text('id_contohujilainnya', 'Contoh Uji Lainnya'),
_multiSelect('visual_dahak_sewaktu', 'Visual dahak Sewaktu', const [
'Nanah Lendir',
'Bercak darah',
'Air liur',
]),
_multiSelect('visual_dahak_pagi', 'Visual dahak Pagi', const [
'Nanah Lendir',
'Bercak darah',
'Air liur',
]),
],
);
}
Widget _testType() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Jenis Pemeriksaan'),
_select('id_jenispemeriksaantb', 'Jenis Pemeriksaan', _tbcTestTypes),
],
);
}
Widget _examinationResultByType() {
final type = widget.selectValues['id_jenispemeriksaantb'] ?? '';
if (type == 'TCM XDR (Xpert)') {
return _xdrResult();
}
if (type == 'Biakan') {
return _cultureResult();
}
if (_tcmRifTypes.contains(type)) {
return _tcmMtbRifResult();
}
return _microscopicResult();
}
Widget _microscopicResult() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Hasil Pemeriksaan Mikroskopis'),
_text('hasilpemeriksaantmikroskopis_tglsewaktu', 'Tanggal Sewaktu'),
_select(
'hasilpemeriksaantmikroskopis_hasilsewaktu',
'Hasil Sewaktu',
_tbMicroscopeResults,
),
_text('hasilpemeriksaantmikroskopis_tglpagi', 'Tanggal Pagi'),
_select(
'hasilpemeriksaantmikroskopis_hasilpagi',
'Hasil Pagi',
_tbMicroscopeResults,
),
],
);
}
Widget _tcmMtbRifResult() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Hasil Pemeriksaan TCM MTB Rif'),
_text('hasilpemeriksaanttcmmtbrifxpert_tglsewaktu', 'Tanggal Sewaktu'),
_text('hasilpemeriksaanttcmmtbrifxpert_kodeunik', 'Kode Unik Sewaktu'),
_select(
'hasilpemeriksaanttcmmtbrifxpert_hasilsewaktu',
'Hasil Sewaktu',
_tcmMtbRifResults,
),
_text('hasilpemeriksaanttcmmtbrifxpert_tglpagi', 'Tanggal Pagi'),
_text('hasilpemeriksaanttcmmtbrifxpert_kodeunikpagi', 'Kode Unik Pagi'),
_select(
'hasilpemeriksaanttcmmtbrifxpert_hasilpagi',
'Hasil Pagi',
_tcmMtbRifResults,
),
],
);
}
Widget _cultureResult() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Hasil Biakan'),
_text('hasilpemeriksaantbbiakan_tgl', 'Tanggal Mikroskopis'),
_select(
'hasilpemeriksaantbbiakan_pagi',
'Mikroskopis Pagi',
_tbMicroscopeResults,
),
_select(
'hasilpemeriksaantbbiakan_sewaktu',
'Mikroskopis Sewaktu',
_tbMicroscopeResults,
),
_text('hasilpemeriksaantbbiakan_tglbiakan', 'Tanggal Biakan'),
_select(
'hasilpemeriksaantbbiakan_hasilbiakanmgit',
'Hasil Biakan (MGIT)',
const ['Pos', 'Neg', 'NTM', 'KTM', 'TDL'],
),
_select(
'hasilpemeriksaantbbiakan_hasilbiakanlj',
'Hasil Biakan (LJ)',
const ['3+', '2+', '1+', '1-9', 'Neg', 'NTM', 'KTM', 'TDL'],
),
],
);
}
Widget _xdrResult() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('TCM XDR (Xpert) - Pagi'),
_text('hasilpemeriksaanttcmmtbxdrxpert_tglpagi', 'Tanggal Pagi'),
_text('hasilpemeriksaanttcmmtbxdrxpert_kodeunikpagi', 'Kode Unik Pagi'),
_xdrPanel('pagi'),
const SizedBox(height: 12),
_sectionTitle('TCM XDR (Xpert) - Sewaktu'),
_text('hasilpemeriksaanttcmmtbxdrxpert_tglsewaktu', 'Tanggal Sewaktu'),
_text('hasilpemeriksaanttcmmtbxdrxpert_kodeunik', 'Kode Unik Sewaktu'),
_xdrPanel('sewaktu'),
],
);
}
Widget _xdrPanel(String prefix) {
final normalized = prefix == 'pagi' ? 'pagi' : 'sewaktu';
final inhLowField = prefix == 'pagi'
? 'hasilpemeriksaanttcmmtbxdrxpert_paginhlow'
: 'hasilpemeriksaanttcmmtbxdrxpert_sewaktunhlow';
return Card(
margin: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
_select(
'hasilpemeriksaanttcmmtbxdrxpert_$normalized',
'TCM XDR',
_xdrValidityResults,
),
_select(
'hasilpemeriksaanttcmmtbxdrxpert_${normalized}mtb',
'MTB',
const ['Detected', 'Not Detected'],
),
_select(inhLowField, 'H Low', _xdrSirResults),
_select(
'hasilpemeriksaanttcmmtbxdrxpert_${normalized}h',
'H',
_xdrSirResults,
),
_select(
'hasilpemeriksaanttcmmtbxdrxpert_${normalized}fqlow',
'FQ Low',
_xdrSirResults,
),
_select(
'hasilpemeriksaanttcmmtbxdrxpert_${normalized}fq',
'FQ',
_xdrSirResults,
),
_select(
'hasilpemeriksaanttcmmtbxdrxpert_${normalized}amk',
'Amk',
_xdrSirResults,
),
_select(
'hasilpemeriksaanttcmmtbxdrxpert_${normalized}km',
'Km',
_xdrSirResults,
),
_select(
'hasilpemeriksaanttcmmtbxdrxpert_${normalized}cm',
'Cm',
_xdrSirResults,
),
_select(
'hasilpemeriksaanttcmmtbxdrxpert_${normalized}eto',
'Eto',
_xdrSirResults,
),
],
),
),
);
}
Widget _antibioticSensitivity() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Tes Kepekaan Antibiotik'),
Text(
'S: Sensitif, I: Intermediate, R: Resisten',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
for (final media in _cultureMedia)
Card(
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(media.label, compact: true),
_select(
media.statusField,
'Status Pertumbuhan',
_growthOptions,
),
_text(media.noteField, 'Catatan'),
],
),
),
),
],
);
}
Widget _toolData() {
return Column(
children: [
_toolSection(
title: 'Vitek',
bacteriaField: 'bakteri',
sirField: 'bakterisir',
colonyField: 'id_bakterihitungkol',
colonyTextField: 'id_bakterihitungkolteks',
printField: 'id_baktericetak',
),
_toolSection(
title: 'MALDITOF',
bacteriaField: 'id_bakteri01',
antibioticField: 'id_antibiotikmanual1',
sirField: 'id_bakterisir01',
colonyField: 'id_bakterihitungkol01',
colonyTextField: 'id_bakterihitungkolteks01',
printField: 'id_bakteri01cetak',
),
_toolSection(
title: 'Bakteri Manual I',
bacteriaField: 'id_bakteri02',
antibioticField: 'id_antibiotikmanual2',
sirField: 'id_bakterisir02',
colonyField: 'id_bakterihitungkol02',
colonyTextField: 'id_bakterihitungkolteks02',
printField: 'id_bakteri02cetak',
),
],
);
}
Widget _finalExpertise() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_text('keterangan', 'Expertise', minLines: 8, maxLines: 12),
CheckboxListTile(
value: widget.criticalValue,
onChanged: (value) => widget.onCriticalChanged(value ?? false),
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: const Text('Nilai Kritis'),
),
FilledButton.icon(
onPressed: widget.saving ? null : () => widget.onSave('Draft'),
icon: widget.saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined),
label: const Text('Save as Draft'),
),
if (widget.isSupervisor) ...[
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('preliminary'),
icon: const Icon(Icons.done_all_outlined),
label: const Text('Save Preliminary result'),
),
OutlinedButton.icon(
onPressed: widget.saving ? null : () => widget.onSave('verifikasi'),
icon: const Icon(Icons.verified_outlined),
label: const Text('Save Final Result'),
),
] else ...[
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('Permohonan Verifikasi'),
icon: const Icon(Icons.send_outlined),
label: const Text('Save and Send To SPV'),
),
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('Permohonan Verifikasi Preliminary'),
icon: const Icon(Icons.outgoing_mail),
label: const Text('Kirim SPV Preliminary'),
),
],
],
);
}
Widget _sectionTitle(String title, {bool compact = false}) {
return Padding(
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800,
color: const Color(0xFF0F766E),
),
),
);
}
Widget _toolSection({
required String title,
required String bacteriaField,
String? antibioticField,
required String sirField,
required String colonyField,
required String colonyTextField,
required String printField,
}) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(title, compact: true),
_text(bacteriaField, 'Bakteri'),
if (antibioticField != null)
_text(antibioticField, 'Set Antibiotik'),
_select(sirField, 'SIR', _sirOptions),
_select(colonyField, 'Hitung Koloni', _colonyOptions),
_text(colonyTextField, 'Hitung Koloni Lainnya'),
_select(printField, 'Cetak', const ['YA', 'TIDAK']),
],
),
),
);
}
Widget _select(String name, String label, List<String> options) {
final current = widget.selectValues[name];
final effectiveOptions = [
if (current != null && current.isNotEmpty && !options.contains(current))
current,
...options,
];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: DropdownButtonFormField<String>(
initialValue:
current != null &&
current.isNotEmpty &&
effectiveOptions.contains(current)
? current
: null,
isExpanded: true,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
items: effectiveOptions
.map(
(option) => DropdownMenuItem(value: option, child: Text(option)),
)
.toList(),
onChanged: (value) {
widget.selectValues[name] = value ?? '';
widget.onChanged();
},
),
);
}
Widget _multiSelect(String name, String label, List<String> options) {
final selected = widget.multiValues[name] ?? <String>{};
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
child: Wrap(
spacing: 8,
runSpacing: 4,
children: options
.map(
(option) => FilterChip(
label: Text(option),
selected: selected.contains(option),
onSelected: (checked) {
if (checked) {
selected.add(option);
} else {
selected.remove(option);
}
widget.multiValues[name] = selected;
widget.onChanged();
},
),
)
.toList(),
),
),
);
}
Widget _text(
String name,
String label, {
int minLines = 1,
int maxLines = 1,
}) {
final controller = widget.textControllers[name] ??= TextEditingController();
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
controller: controller,
minLines: minLines,
maxLines: maxLines,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
),
);
}
}
const List<String> _tbcTestTypes = [
'Mikroskopis',
'TCM MTB Rif (Xpert)',
'TCM XDR (Xpert)',
'TCM MTB Rif (Truenat)',
'TCM MTB Rif INH (BDMAX)',
'PCR Open System',
'LPA lini 1',
'LPA lini 2',
'Biakan',
'Paket standar uji kepekaan',
];
const Set<String> _tcmRifTypes = {
'TCM MTB Rif (Xpert)',
'TCM MTB Rif (Truenat)',
'TCM MTB Rif INH (BDMAX)',
'LPA lini 1',
'LPA lini 2',
};
const List<String> _tbMicroscopeResults = ['+++', '++', '+', '1-9', 'Neg'];
const List<String> _tcmMtbRifResults = [
'Neg',
'Rif Sen',
'Rif Res',
'Rif Indet',
'Invalid',
'Error',
'No result',
];
const List<String> _xdrValidityResults = [
'Valid',
'Invalid',
'No Result',
'Error',
];
const List<String> _xdrSirResults = [
'S',
'I',
'R',
'Invalid',
'Error',
'No Result',
];
@@ -0,0 +1,55 @@
part of '../../app/app.dart';
class TemplatePicker extends StatelessWidget {
const TemplatePicker({
super.key,
required this.templates,
required this.saving,
required this.onUse,
});
final List<dynamic> templates;
final bool saving;
final ValueChanged<String> onUse;
@override
Widget build(BuildContext context) {
final knownTemplates = templates
.map(asMap)
.where((template) => _knownDlp.contains(template['judul']?.toString()))
.toList();
if (knownTemplates.isEmpty) {
return const EmptyPanel(text: 'Template expertise belum tersedia.');
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionTitle(title: 'Pilih Template'),
...knownTemplates.map(
(template) => Card(
margin: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: ListTile(
leading: const Icon(
Icons.article_outlined,
color: Color(0xFF0F766E),
),
title: Text(template['judul']?.toString() ?? '-'),
subtitle: Text('Kategori ${template['kategori'] ?? '-'}'),
trailing: FilledButton(
onPressed: saving
? null
: () => onUse(template['judul'].toString()),
child: const Text('Pakai'),
),
),
),
),
],
);
}
}
@@ -0,0 +1,419 @@
part of '../../app/app.dart';
class ViralLoadExpertiseWizard extends StatefulWidget {
const ViralLoadExpertiseWizard({
super.key,
required this.item,
required this.user,
required this.staffOptions,
required this.optionSets,
required this.textControllers,
required this.selectValues,
required this.staffValues,
required this.criticalValue,
required this.isSupervisor,
required this.saving,
required this.onCriticalChanged,
required this.onChanged,
required this.onSave,
});
final Map<String, dynamic> item;
final Map<String, dynamic> user;
final Map<String, dynamic> staffOptions;
final Map<String, dynamic> optionSets;
final Map<String, TextEditingController> textControllers;
final Map<String, String> selectValues;
final Map<String, String> staffValues;
final bool criticalValue;
final bool isSupervisor;
final bool saving;
final ValueChanged<bool> onCriticalChanged;
final VoidCallback onChanged;
final ValueChanged<String> onSave;
@override
State<ViralLoadExpertiseWizard> createState() =>
_ViralLoadExpertiseWizardState();
}
class _ViralLoadExpertiseWizardState extends State<ViralLoadExpertiseWizard> {
int _step = 0;
void _next() {
setState(() => _step = (_step + 1).clamp(0, 4));
}
void _previous() {
setState(() => _step = (_step - 1).clamp(0, 4));
}
@override
Widget build(BuildContext context) {
return Stepper(
currentStep: _step,
onStepTapped: (step) => setState(() => _step = step),
controlsBuilder: (context, details) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
OutlinedButton.icon(
onPressed: _step == 0 ? null : _previous,
icon: const Icon(Icons.chevron_left),
label: const Text('Previous'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _step == 4 ? null : _next,
icon: const Icon(Icons.chevron_right),
label: const Text('Next'),
),
],
),
);
},
steps: [
Step(
title: const Text('Pasien & Petugas'),
isActive: _step == 0,
content: PatientStaffPanel(
item: widget.item,
user: widget.user,
staffOptions: widget.staffOptions,
staffValues: widget.staffValues,
onChanged: widget.onChanged,
),
),
Step(
title: const Text('Parameter'),
isActive: _step == 1,
content: _parameters(),
),
Step(
title: const Text('Tes Kepekaan Antibiotik'),
isActive: _step == 2,
content: _antibioticSensitivity(),
),
Step(
title: const Text('Data Alat'),
isActive: _step == 3,
content: _toolData(),
),
Step(
title: const Text('Expertise'),
isActive: _step == 4,
content: _finalExpertise(),
),
],
);
}
Widget _parameters() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Parameter Viral Load'),
_select(
'vl_parameter01',
'Parameter',
_options('jsonvlparameter', const ['HIV RNA', 'HCV RNA', 'HBV DNA']),
),
_select('vl_hasil01', 'Hasil', _viralLoadResultOptions),
_text('vl_hasil01teks', 'Hasil Manual'),
_select('vl_satuan01', 'Satuan', const ['copies/mL', 'IU/mL']),
_select('vl_rujukan01', 'Nilai Rujukan', const [
'40-10.000.000',
'10-100.000.000',
]),
const SizedBox(height: 8),
_sectionTitle('Parameter 02', compact: true),
_select('vl_parameter02', 'Parameter 02', const ['LOG']),
_select('vl_hasil02', 'Hasil 02', _viralLoadLogResultOptions),
_text('vl_hasil02teks', 'Hasil 02 Manual'),
_select('vl_satuan02', 'Satuan 02', const ['LOG']),
_select('vl_rujukan02', 'Nilai Rujukan 02', const ['1,6-7', ' ']),
_text('viralload', 'Catatan Viral Load', minLines: 6, maxLines: 9),
],
);
}
Widget _antibioticSensitivity() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle('Tes Kepekaan Antibiotik'),
Text(
'S: Sensitif, I: Intermediate, R: Resisten',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: const [
BadgeLabel(text: 'Media BAP', color: Color(0xFF2563EB)),
BadgeLabel(text: 'Media CAP', color: Color(0xFFDC2626)),
BadgeLabel(text: 'Mc Conkey', color: Color(0xFF0891B2)),
BadgeLabel(text: 'Media Jamur', color: Color(0xFFD97706)),
],
),
const SizedBox(height: 12),
for (final media in _cultureMedia)
Card(
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(media.label, compact: true),
_select(
media.statusField,
'Status Pertumbuhan',
_growthOptions,
),
_text(media.noteField, 'Catatan'),
],
),
),
),
],
);
}
Widget _toolData() {
return Column(
children: [
_toolSection(
title: 'Vitek',
bacteriaField: 'bakteri',
sirField: 'bakterisir',
colonyField: 'id_bakterihitungkol',
colonyTextField: 'id_bakterihitungkolteks',
printField: 'id_baktericetak',
),
_toolSection(
title: 'MALDITOF',
bacteriaField: 'id_bakteri01',
antibioticField: 'id_antibiotikmanual1',
sirField: 'id_bakterisir01',
colonyField: 'id_bakterihitungkol01',
colonyTextField: 'id_bakterihitungkolteks01',
printField: 'id_bakteri01cetak',
),
_toolSection(
title: 'Bakteri Manual I',
bacteriaField: 'id_bakteri02',
antibioticField: 'id_antibiotikmanual2',
sirField: 'id_bakterisir02',
colonyField: 'id_bakterihitungkol02',
colonyTextField: 'id_bakterihitungkolteks02',
printField: 'id_bakteri02cetak',
),
],
);
}
Widget _finalExpertise() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_text('keterangan', 'Expertise', minLines: 8, maxLines: 12),
CheckboxListTile(
value: widget.criticalValue,
onChanged: (value) => widget.onCriticalChanged(value ?? false),
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: const Text('Nilai Kritis'),
),
FilledButton.icon(
onPressed: widget.saving ? null : () => widget.onSave('Draft'),
icon: widget.saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined),
label: const Text('Save as Draft'),
),
if (widget.isSupervisor) ...[
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('preliminary'),
icon: const Icon(Icons.done_all_outlined),
label: const Text('Save Preliminary result'),
),
OutlinedButton.icon(
onPressed: widget.saving ? null : () => widget.onSave('verifikasi'),
icon: const Icon(Icons.verified_outlined),
label: const Text('Save Final Result'),
),
] else ...[
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('Permohonan Verifikasi'),
icon: const Icon(Icons.send_outlined),
label: const Text('Save and Send To SPV'),
),
OutlinedButton.icon(
onPressed: widget.saving
? null
: () => widget.onSave('Permohonan Verifikasi Preliminary'),
icon: const Icon(Icons.outgoing_mail),
label: const Text('Kirim SPV Preliminary'),
),
],
],
);
}
List<String> _options(
String key, [
List<String> fallback = const ['Pilih Salah Satu'],
]) {
final values = asList(
widget.optionSets[key],
).map((item) => item.toString()).where((item) => item.isNotEmpty).toList();
return values.isEmpty ? fallback : values;
}
Widget _sectionTitle(String title, {bool compact = false}) {
return Padding(
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800,
color: const Color(0xFF0F766E),
),
),
);
}
Widget _toolSection({
required String title,
required String bacteriaField,
String? antibioticField,
required String sirField,
required String colonyField,
required String colonyTextField,
required String printField,
}) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(title, compact: true),
_text(bacteriaField, 'Bakteri'),
if (antibioticField != null)
_text(antibioticField, 'Set Antibiotik'),
_select(sirField, 'SIR', _sirOptions),
_select(colonyField, 'Hitung Koloni', _colonyOptions),
_text(colonyTextField, 'Hitung Koloni Lainnya'),
_select(printField, 'Cetak', const ['YA', 'TIDAK']),
],
),
),
);
}
Widget _select(String name, String label, List<String> options) {
final current = widget.selectValues[name];
final effectiveOptions = [
if (current != null && current.isNotEmpty && !options.contains(current))
current,
...options,
];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: DropdownButtonFormField<String>(
initialValue:
current != null &&
current.isNotEmpty &&
effectiveOptions.contains(current)
? current
: null,
isExpanded: true,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
items: effectiveOptions
.map(
(option) => DropdownMenuItem(value: option, child: Text(option)),
)
.toList(),
onChanged: (value) {
widget.selectValues[name] = value ?? '';
widget.onChanged();
},
),
);
}
Widget _text(
String name,
String label, {
int minLines = 1,
int maxLines = 1,
}) {
final controller = widget.textControllers[name] ??= TextEditingController();
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
controller: controller,
minLines: minLines,
maxLines: maxLines,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
),
);
}
}
const List<String> _viralLoadResultOptions = [
'NOT DETECTED',
'<40',
'<1.60',
'40',
'41',
'44',
'45',
'145',
'<10',
'20',
'322',
'511',
'144',
'211',
'lainnya',
];
const List<String> _viralLoadLogResultOptions = [
'NOT DETECTED',
'<1,60',
'<1,85',
'1,60',
'1,61',
'1,64',
'1,65',
'2,16',
'1,00',
'1,30',
'2,51',
'2,71',
'2,34',
'lainnya',
];
+254
View File
@@ -0,0 +1,254 @@
part of '../../app/app.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _baseUrl = TextEditingController(text: kDefaultBaseUrl);
final _username = TextEditingController();
final _password = TextEditingController();
bool _obscure = true;
bool _loading = false;
bool _pinging = false;
String? _pingStatus;
@override
void initState() {
super.initState();
SessionStore.baseUrl().then((value) {
if (!mounted) {
return;
}
_baseUrl.text = value;
});
}
@override
void dispose() {
_baseUrl.dispose();
_username.dispose();
_password.dispose();
super.dispose();
}
Future<void> _login() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() => _loading = true);
try {
final serverUrl = normalizeBaseUrl(_baseUrl.text);
await SessionStore.saveBaseUrl(serverUrl);
final api = ApiClient(baseUrl: serverUrl);
final data = await api.post('api/mobile/login', {
'username': _username.text.trim(),
'password': _password.text,
});
final token = data['token']?.toString();
if (token == null || token.isEmpty) {
throw ApiException('Token login tidak diterima dari server.');
}
await SessionStore.saveToken(token);
if (!mounted) {
return;
}
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => DashboardScreen(token: token, baseUrl: serverUrl),
),
);
} on ApiException catch (error) {
_showMessage(error.message);
} catch (_) {
_showMessage('Tidak bisa terhubung ke server MyLIS.');
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _ping() async {
final serverUrl = normalizeBaseUrl(_baseUrl.text);
if (serverUrl.isEmpty) {
return;
}
setState(() {
_pinging = true;
_pingStatus = null;
});
try {
await SessionStore.saveBaseUrl(serverUrl);
final data = await ApiClient(baseUrl: serverUrl).get('api/mobile/ping');
final appName = data['application']?.toString();
setState(() {
_baseUrl.text = serverUrl;
_pingStatus = appName == null || appName.isEmpty
? 'Server bisa diakses.'
: 'Server bisa diakses: $appName';
});
} on ApiException catch (error) {
setState(() => _pingStatus = error.message);
} catch (_) {
setState(() => _pingStatus = 'Koneksi server gagal.');
} finally {
if (mounted) setState(() => _pinging = false);
}
}
void _showMessage(String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(
Icons.biotech_outlined,
size: 72,
color: Color(0xFF0F766E),
),
const SizedBox(height: 16),
Text(
'MyLIS Mobile',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 6),
Text(
'Login petugas',
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: Colors.black54),
),
const SizedBox(height: 32),
TextFormField(
controller: _baseUrl,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.next,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.link),
labelText: 'URL Laravel',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
tooltip: 'Ping server',
onPressed: _pinging ? null : _ping,
icon: _pinging
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.network_ping),
),
),
validator: (value) {
final url = normalizeBaseUrl(value ?? '');
final uri = Uri.tryParse(url);
return uri == null ||
!uri.hasScheme ||
!uri.hasAuthority
? 'URL Laravel tidak valid'
: null;
},
),
if (_pingStatus != null) ...[
const SizedBox(height: 8),
Text(
_pingStatus!,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: _pingStatus!.contains('bisa diakses')
? const Color(0xFF15803D)
: const Color(0xFFDC2626),
),
),
],
const SizedBox(height: 14),
TextFormField(
controller: _username,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
prefixIcon: Icon(Icons.person_outline),
labelText: 'Username',
border: OutlineInputBorder(),
),
validator: (value) =>
value == null || value.trim().isEmpty
? 'Username wajib diisi'
: null,
),
const SizedBox(height: 14),
TextFormField(
controller: _password,
obscureText: _obscure,
onFieldSubmitted: (_) => _login(),
decoration: InputDecoration(
prefixIcon: const Icon(Icons.lock_outline),
labelText: 'Password',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
tooltip: _obscure
? 'Tampilkan password'
: 'Sembunyikan password',
onPressed: () => setState(() => _obscure = !_obscure),
icon: Icon(
_obscure
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
),
),
validator: (value) => value == null || value.isEmpty
? 'Password wajib diisi'
: null,
),
const SizedBox(height: 22),
FilledButton.icon(
onPressed: _loading ? null : _login,
icon: _loading
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.login),
label: const Text('Login'),
),
const SizedBox(height: 18),
Text(
'Default: $kDefaultBaseUrl',
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.black45),
),
],
),
),
),
),
),
),
);
}
}
+59
View File
@@ -0,0 +1,59 @@
part of '../app/app.dart';
class ApiClient {
ApiClient({required this.baseUrl, this.token});
final String baseUrl;
final String? token;
Uri _uri(String path, [Map<String, String>? query]) {
final normalizedBase = normalizeBaseUrl(baseUrl);
final base = Uri.parse(normalizedBase);
return base.replace(
path: '${base.path.replaceAll(RegExp(r'/$'), '')}/$path',
queryParameters: query,
);
}
Map<String, String> get _headers => {
'Accept': 'application/json',
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
};
Future<Map<String, dynamic>> post(
String path,
Map<String, dynamic> body,
) async {
final response = await http.post(
_uri(path),
headers: _headers,
body: jsonEncode(body),
);
return _decode(response);
}
Future<Map<String, dynamic>> get(
String path, [
Map<String, String>? query,
]) async {
final response = await http.get(_uri(path, query), headers: _headers);
return _decode(response);
}
Map<String, dynamic> _decode(http.Response response) {
final decoded = response.body.isEmpty
? <String, dynamic>{}
: jsonDecode(response.body);
if (response.statusCode >= 200 && response.statusCode < 300) {
return decoded is Map<String, dynamic> ? decoded : {'data': decoded};
}
final message = decoded is Map ? decoded['message'] : null;
throw ApiException(message?.toString() ?? 'Koneksi ke server gagal.');
}
}
class ApiException implements Exception {
ApiException(this.message);
final String message;
}
+57
View File
@@ -0,0 +1,57 @@
part of '../app/app.dart';
class SessionStore {
static const _tokenKey = 'mylis_token';
static const _baseUrlKey = 'mylis_base_url';
static Future<String?> token() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_tokenKey);
}
static Future<String> baseUrl() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_baseUrlKey) ?? kDefaultBaseUrl;
}
static Future<SessionData> session() async {
final prefs = await SharedPreferences.getInstance();
return SessionData(
token: prefs.getString(_tokenKey),
baseUrl: prefs.getString(_baseUrlKey) ?? kDefaultBaseUrl,
);
}
static Future<void> saveToken(String token) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, token);
}
static Future<void> saveBaseUrl(String baseUrl) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_baseUrlKey, normalizeBaseUrl(baseUrl));
}
static Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_tokenKey);
}
}
class SessionData {
const SessionData({this.token, this.baseUrl = kDefaultBaseUrl});
final String? token;
final String baseUrl;
}
String normalizeBaseUrl(String value) {
var url = value.trim();
if (url.isEmpty) {
return kDefaultBaseUrl;
}
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://$url';
}
return url.replaceAll(RegExp(r'/+$'), '');
}
+140
View File
@@ -0,0 +1,140 @@
part of '../app/app.dart';
class DetailRow extends StatelessWidget {
const DetailRow({super.key, required this.label, required this.value});
final String label;
final Object? value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(color: Colors.black54, fontSize: 12),
),
const SizedBox(height: 2),
Text(
value?.toString().isNotEmpty == true ? value.toString() : '-',
style: const TextStyle(fontWeight: FontWeight.w600),
),
],
),
);
}
}
class StatusPill extends StatelessWidget {
const StatusPill({super.key, required this.status});
final String status;
@override
Widget build(BuildContext context) {
final lower = status.toLowerCase();
final color = lower.contains('selesai')
? const Color(0xFF15803D)
: lower.contains('dibatalkan')
? const Color(0xFF6B7280)
: lower.contains('warning') || lower.contains('target')
? const Color(0xFFDC2626)
: const Color(0xFF0F766E);
return BadgeLabel(text: status, color: color);
}
}
class BadgeLabel extends StatelessWidget {
const BadgeLabel({super.key, required this.text, required this.color});
final String text;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
),
child: Text(
text,
style: TextStyle(
color: color,
fontWeight: FontWeight.w700,
fontSize: 12,
),
),
);
}
}
class EmptyPanel extends StatelessWidget {
const EmptyPanel({super.key, required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text(text, style: const TextStyle(color: Colors.black54)),
),
);
}
}
class ErrorView extends StatelessWidget {
const ErrorView({super.key, required this.message, required this.onRetry});
final String message;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 42, color: Color(0xFFDC2626)),
const SizedBox(height: 10),
Text(message, textAlign: TextAlign.center),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Coba Lagi'),
),
],
),
),
);
}
}
class LoadingScreen extends StatelessWidget {
const LoadingScreen({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
}
Map<String, dynamic> asMap(Object? value) {
if (value is Map<String, dynamic>) {
return value;
}
if (value is Map) {
return value.map((key, item) => MapEntry(key.toString(), item));
}
return <String, dynamic>{};
}
List<dynamic> asList(Object? value) => value is List ? value : <dynamic>[];
String _message(Object? error) => error is ApiException
? error.message
: 'Terjadi kesalahan saat memuat data.';