Files
lis/mylis/lib/screens/dashboard/specimen_register_screen.dart
T
2026-08-02 10:46:34 +07:00

766 lines
23 KiB
Dart

part of '../../app/app.dart';
class SpecimenRegisterScreen extends StatefulWidget {
const SpecimenRegisterScreen({
super.key,
required this.token,
required this.baseUrl,
required this.master,
required this.title,
required this.total,
required this.user,
required this.notificationCount,
});
final String token;
final String baseUrl;
final String master;
final String title;
final int total;
final Map<String, dynamic> user;
final int notificationCount;
@override
State<SpecimenRegisterScreen> createState() => _SpecimenRegisterScreenState();
}
class _SpecimenRegisterScreenState extends State<SpecimenRegisterScreen> {
late final ApiClient _api;
late Future<Map<String, dynamic>> _future;
final _search = TextEditingController();
static const _allSpecimens = '__all_specimens__';
static const _allStatuses = '__all_statuses__';
static const int _pageSize = 10;
String _specimen = _allSpecimens;
String _status = _allStatuses;
int _page = 0;
@override
void initState() {
super.initState();
_api = ApiClient(baseUrl: widget.baseUrl, token: widget.token);
_future = _load();
_search.addListener(_onSearchChanged);
}
@override
void dispose() {
_search.removeListener(_onSearchChanged);
_search.dispose();
super.dispose();
}
Future<Map<String, dynamic>> _load() {
return _api.get('api/mobile/books/${widget.master}/examinations');
}
void _reload() {
setState(() {
_page = 0;
_future = _load();
});
}
void _onSearchChanged() {
if (!mounted) return;
setState(() {
_page = 0;
});
}
Future<void> _openCriticalValues() async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
CriticalValuesScreen(token: widget.token, baseUrl: widget.baseUrl),
),
);
}
void _showProfile() {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
CircleAvatar(
radius: 28,
backgroundColor: const Color(0xFF0F766E),
child: Text(
_initials(widget.user['nama']?.toString() ?? 'SP'),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w900,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.user['nama']?.toString() ?? '-',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 2),
Text(
'${widget.user['username'] ?? '-'}${widget.user['previlage'] ?? '-'}',
style: const TextStyle(color: Colors.black54),
),
],
),
),
],
),
const SizedBox(height: 18),
FilledButton.icon(
onPressed: () async {
await SessionStore.clear();
if (!context.mounted) return;
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const LoginScreen()),
(_) => false,
);
},
icon: const Icon(Icons.logout),
label: const Text('Logout'),
),
],
),
),
),
);
}
String _initials(String name) {
final parts = name
.trim()
.split(RegExp(r'\s+'))
.where((part) => part.isNotEmpty)
.toList();
if (parts.isEmpty) {
return 'SP';
}
return parts.take(2).map((part) => part[0].toUpperCase()).join();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: FutureBuilder<Map<String, dynamic>>(
future: _future,
builder: (context, snapshot) {
final items = asList(snapshot.data?['items']);
final filtered = _filtered(items);
final specimens = _specimenOptions(items);
final statuses = _statusOptions(items);
final maxPage = filtered.isEmpty
? 0
: (filtered.length - 1) ~/ _pageSize;
final page = _page.clamp(0, maxPage);
final start = filtered.isEmpty ? 0 : page * _pageSize;
final end = filtered.isEmpty
? 0
: (start + _pageSize).clamp(0, filtered.length);
final visible = filtered.sublist(start, end);
return ListView(
padding: const EdgeInsets.fromLTRB(18, 12, 18, 24),
children: [
_SpecimenHeader(
title: widget.title,
total: filtered.length,
user: widget.user,
notificationCount: widget.notificationCount,
onNotifications: _openCriticalValues,
onProfile: _showProfile,
),
const SizedBox(height: 24),
_FilterDropdown(
label: 'Jenis Spesimen',
value: _specimen,
icon: Icons.science_outlined,
options: specimens,
onChanged: (value) => setState(() {
_specimen = value;
_status = _allStatuses;
_page = 0;
}),
),
const SizedBox(height: 18),
_FilterDropdown(
label: 'Status Pengerjaan',
value: _status,
icon: Icons.assignment_outlined,
options: statuses,
onChanged: (value) => setState(() {
_status = value;
_page = 0;
}),
),
const SizedBox(height: 22),
Text(
'Cari Spesimen',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF080D3D),
),
),
const SizedBox(height: 10),
_SearchBlock(search: _search),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 18),
_RegisterTitle(title: widget.title, total: filtered.length),
const SizedBox(height: 14),
if (snapshot.connectionState != ConnectionState.done)
const SizedBox(
height: 180,
child: Center(child: CircularProgressIndicator()),
)
else if (snapshot.hasError)
ErrorView(message: _message(snapshot.error), onRetry: _reload)
else if (filtered.isEmpty)
const EmptyPanel(text: 'Tidak ada sampel.')
else ...[
...visible.map(
(item) => SpecimenSampleCard(
item: asMap(item),
token: widget.token,
baseUrl: widget.baseUrl,
),
),
const SizedBox(height: 16),
_SpecimenPager(
start: start + 1,
end: end,
total: filtered.length,
canPrevious: page > 0,
canNext: page < maxPage,
onPrevious: () => setState(() => _page = page - 1),
onNext: () => setState(() => _page = page + 1),
),
],
],
);
},
),
),
bottomNavigationBar: NavigationBar(
selectedIndex: 1,
onDestinationSelected: (index) {
if (index == 0) {
Navigator.of(context).popUntil((route) => route.isFirst);
}
if (index == 2) {
Navigator.of(context).popUntil((route) => route.isFirst);
}
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
label: 'Beranda',
),
NavigationDestination(icon: Icon(Icons.search), label: 'Cari Sampel'),
NavigationDestination(
icon: Icon(Icons.person_outline),
label: 'Profil',
),
],
),
);
}
List<dynamic> _filtered(List<dynamic> items) {
final query = _search.text.trim().toLowerCase();
return items.where((raw) {
final item = asMap(raw);
final specimenValue = _specimenValue(item);
final specimenOk =
_specimen == _allSpecimens || specimenValue == _specimen;
final statusOk =
_status == _allStatuses || item['status']?.toString() == _status;
final searchOk =
query.isEmpty ||
[
item['nofoto'],
item['noregister'],
item['nmpasien'],
item['reques'],
item['kd_spesimen'],
item['nm_spesimen'],
item['status'],
].whereType<Object>().join(' ').toLowerCase().contains(query);
return specimenOk && statusOk && searchOk;
}).toList();
}
List<_FilterOption> _specimenOptions(List<dynamic> items) {
final values = <String, String>{};
for (final raw in items) {
final item = asMap(raw);
final value = _specimenValue(item);
final label = item['nm_spesimen']?.toString() ?? '';
if (value.isNotEmpty && label.isNotEmpty) {
values.putIfAbsent(value, () => label);
}
}
final options =
values.entries
.map((entry) => _FilterOption(value: entry.key, label: entry.value))
.toList()
..sort((a, b) => a.label.compareTo(b.label));
return [
const _FilterOption(value: _allSpecimens, label: 'Semua Spesimen'),
...options,
];
}
List<_FilterOption> _statusOptions(List<dynamic> items) {
final filteredBySpecimen = items.where((raw) {
final item = asMap(raw);
return _specimen == _allSpecimens || _specimenValue(item) == _specimen;
});
final values =
filteredBySpecimen
.map((item) => asMap(item)['status']?.toString() ?? '')
.where((value) => value.isNotEmpty)
.toSet()
.toList()
..sort();
return [
const _FilterOption(value: _allStatuses, label: 'Semua Status'),
...values.map((value) => _FilterOption(value: value, label: value)),
];
}
String _specimenValue(Map<String, dynamic> item) {
final code = item['kd_spesimen']?.toString() ?? '';
if (code.isNotEmpty) {
return code;
}
return item['nm_spesimen']?.toString() ?? '';
}
}
class _FilterOption {
const _FilterOption({required this.value, required this.label});
final String value;
final String label;
}
class _SpecimenHeader extends StatelessWidget {
const _SpecimenHeader({
required this.title,
required this.total,
required this.user,
required this.notificationCount,
required this.onNotifications,
required this.onProfile,
});
final String title;
final int total;
final Map<String, dynamic> user;
final int notificationCount;
final VoidCallback onNotifications;
final VoidCallback onProfile;
@override
Widget build(BuildContext context) {
return Row(
children: [
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.arrow_back),
),
const SizedBox(width: 6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF080D3D),
),
),
),
const SizedBox(width: 10),
BadgeLabel(text: '$total', color: const Color(0xFF6D28D9)),
],
),
const SizedBox(height: 4),
Text(
'Catatan & Register',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: const Color(0xFF47517A),
fontWeight: FontWeight.w700,
),
),
],
),
),
Stack(
clipBehavior: Clip.none,
children: [
IconButton(
onPressed: onNotifications,
icon: const Icon(Icons.notifications_none_rounded, size: 30),
color: const Color(0xFF063B60),
tooltip: 'Notifikasi nilai kritis',
),
if (notificationCount > 0)
Positioned(
top: 6,
right: 6,
child: Container(
padding: const EdgeInsets.all(5),
constraints: const BoxConstraints(minWidth: 20),
decoration: const BoxDecoration(
color: Color(0xFFFF1D25),
shape: BoxShape.circle,
),
child: Text(
notificationCount > 99 ? '99+' : '$notificationCount',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w800,
),
),
),
),
],
),
const SizedBox(width: 14),
InkWell(
borderRadius: BorderRadius.circular(24),
onTap: onProfile,
child: CircleAvatar(
backgroundColor: const Color(0xFF12BFA5),
child: Text(
_initials(user['nama']?.toString() ?? 'SP'),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w900,
),
),
),
),
],
);
}
String _initials(String name) {
final parts = name
.trim()
.split(RegExp(r'\s+'))
.where((part) => part.isNotEmpty)
.toList();
if (parts.isEmpty) {
return 'SP';
}
return parts.take(2).map((part) => part[0].toUpperCase()).join();
}
}
class _FilterDropdown extends StatelessWidget {
const _FilterDropdown({
required this.label,
required this.value,
required this.icon,
required this.options,
required this.onChanged,
});
final String label;
final String value;
final IconData icon;
final List<_FilterOption> options;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
final values = options.map((option) => option.value).toSet();
final effectiveValue = values.contains(value) ? value : options.first.value;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: const Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 10),
DropdownButtonFormField<String>(
initialValue: effectiveValue,
isExpanded: true,
selectedItemBuilder: (context) => options
.map(
(option) => Align(
alignment: Alignment.centerLeft,
child: Text(
option.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
)
.toList(),
decoration: InputDecoration(
prefixIcon: Icon(icon, color: const Color(0xFF6D28D9)),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFFA875FF)),
),
),
items: options
.map(
(option) => DropdownMenuItem(
value: option.value,
child: Text(option.label, overflow: TextOverflow.ellipsis),
),
)
.toList(),
onChanged: (value) => onChanged(value ?? options.first.value),
),
],
);
}
}
class _SearchBlock extends StatelessWidget {
const _SearchBlock({required this.search});
final TextEditingController search;
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
controller: search,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
hintText: 'Ketik No. Sampel, No. RM, atau Nama Pasien',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: BadgeLabel(
text: 'Pencarian: No. Sampel / No. RM / Nama',
color: const Color(0xFF6D28D9),
),
),
],
);
}
}
class _SpecimenPager extends StatelessWidget {
const _SpecimenPager({
required this.start,
required this.end,
required this.total,
required this.canPrevious,
required this.canNext,
required this.onPrevious,
required this.onNext,
});
final int start;
final int end;
final int total;
final bool canPrevious;
final bool canNext;
final VoidCallback onPrevious;
final VoidCallback onNext;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Text(
'$start-$end dari $total sampel',
style: const TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w800,
),
),
),
const Text(
'Tampilkan',
style: TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w800,
),
),
const SizedBox(width: 10),
BadgeLabel(text: '10', color: const Color(0xFF667095)),
const SizedBox(width: 8),
IconButton.filledTonal(
onPressed: canPrevious ? onPrevious : null,
icon: const Icon(Icons.chevron_left_rounded),
),
const SizedBox(width: 8),
IconButton.filledTonal(
onPressed: canNext ? onNext : null,
icon: const Icon(Icons.chevron_right_rounded),
),
],
);
}
}
class _RegisterTitle extends StatelessWidget {
const _RegisterTitle({required this.title, required this.total});
final String title;
final int total;
@override
Widget build(BuildContext context) {
return Row(
children: [
const Icon(Icons.menu_book_rounded, color: Color(0xFF6D28D9)),
const SizedBox(width: 10),
Expanded(
child: Text(
'BUKU REGISTER ${title.toUpperCase()}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: const Color(0xFF6D28D9),
fontWeight: FontWeight.w900,
),
),
),
Text(
'$total',
style: const TextStyle(
color: Color(0xFF667095),
fontWeight: FontWeight.w900,
),
),
],
);
}
}
class SpecimenSampleCard extends StatelessWidget {
const SpecimenSampleCard({
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: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Color(0xFFE1E8F5)),
),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _openDetail(context, item, token, baseUrl),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 6,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item['nofoto']?.toString() ?? '-',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: const Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 6),
BadgeLabel(
text: item['reques']?.toString() ?? '-',
color: const Color(0xFF0891B2),
),
const SizedBox(height: 8),
Text(
item['nmpasien']?.toString() ?? '-',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w700,
),
),
Text(
'No.RM ${item['noregister'] ?? '-'}',
style: const TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w700,
),
),
],
),
),
Container(width: 1, height: 96, color: const Color(0xFFE1E8F5)),
const SizedBox(width: 14),
Expanded(
flex: 4,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Status :',
style: TextStyle(
color: Color(0xFF667095),
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 10),
StatusPill(status: item['status']?.toString() ?? 'NEW'),
],
),
),
],
),
),
),
);
}
}