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

1437 lines
42 KiB
Dart

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;
final _homeSearch = TextEditingController();
int _selectedTab = 0;
@override
void initState() {
super.initState();
_api = ApiClient(baseUrl: widget.baseUrl, token: widget.token);
_future = _api.get('api/mobile/dashboard');
}
@override
void dispose() {
_homeSearch.dispose();
super.dispose();
}
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()));
}
Future<void> _searchAndOpenDetail(String query) async {
final keyword = query.trim();
if (keyword.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Masukkan kata kunci sampel.')),
);
return;
}
try {
final data = await _api.get('api/mobile/examinations/search', {
'search': keyword,
});
final item = asMap(data['item']);
final id = (item['id'] as num?)?.toInt();
if (id == null) {
throw ApiException('Data pemeriksaan tidak ditemukan.');
}
if (!mounted) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ExaminationDetailScreen(
token: widget.token,
baseUrl: widget.baseUrl,
id: id,
),
),
);
} on ApiException catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.message)));
}
}
Future<void> _scanAndSearch() async {
final result = await Navigator.of(context).push<String>(
MaterialPageRoute(builder: (_) => const BarcodeScannerScreen()),
);
if (result == null || result.trim().isEmpty) {
return;
}
_homeSearch.text = result.trim();
await _searchAndOpenDetail(result);
}
void _showSearchSheet() {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (context) => SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(
18,
0,
18,
MediaQuery.viewInsetsOf(context).bottom + 18,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Cari Sampel',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 12),
TextField(
controller: _homeSearch,
autofocus: true,
textInputAction: TextInputAction.search,
onSubmitted: (value) {
Navigator.of(context).pop();
_searchAndOpenDetail(value);
},
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
labelText: 'No. Sampel / No. RM / Nama Pasien',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
onPressed: () async {
Navigator.of(context).pop();
await _scanAndSearch();
},
icon: const Icon(Icons.qr_code_scanner_rounded),
tooltip: 'Scan barcode',
),
),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: () {
Navigator.of(context).pop();
_searchAndOpenDetail(_homeSearch.text);
},
icon: const Icon(Icons.manage_search_rounded),
label: const Text('Cari dan Buka Detail'),
),
],
),
),
),
);
}
void _openBook(Map<String, dynamic> book) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ExaminationListScreen(
token: widget.token,
baseUrl: widget.baseUrl,
master: book['master']?.toString() ?? 'buku0',
title: book['label']?.toString() ?? 'Pemeriksaan',
),
),
);
}
void _openEws(List<dynamic> warnings) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => EwsScreen(
warnings: warnings,
token: widget.token,
baseUrl: widget.baseUrl,
),
),
);
}
void _openInitialWork(Map<String, dynamic> user, int notificationCount) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => InitialWorkScreen(
token: widget.token,
baseUrl: widget.baseUrl,
user: user,
notificationCount: notificationCount,
),
),
);
}
void _openSampleGroups(
List<dynamic> books,
Map<String, dynamic> user,
int notificationCount,
) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => SampleGroupsScreen(
books: books,
token: widget.token,
baseUrl: widget.baseUrl,
user: user,
notificationCount: notificationCount,
),
),
);
}
void _openAllSpecimens(Map<String, dynamic> user, int notificationCount) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => SpecimenRegisterScreen(
token: widget.token,
baseUrl: widget.baseUrl,
master: 'ALL',
title: 'Semua Spesimen',
total: 0,
user: user,
notificationCount: notificationCount,
),
),
);
}
Future<void> _openCriticalValues() async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
CriticalValuesScreen(token: widget.token, baseUrl: widget.baseUrl),
),
);
if (mounted) {
_reload();
}
}
void _showProfile(Map<String, dynamic> user) {
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(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(
user['nama']?.toString() ?? '-',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 2),
Text(
'${user['username'] ?? '-'}${user['previlage'] ?? '-'}',
style: const TextStyle(color: Colors.black54),
),
],
),
),
],
),
const SizedBox(height: 18),
FilledButton.icon(
onPressed: () {
Navigator.of(context).pop();
_logout();
},
icon: const Icon(Icons.logout),
label: const Text('Logout'),
),
],
),
),
),
);
}
void _handleBottomNav(int value, Map<String, dynamic> user) {
setState(() => _selectedTab = value);
if (value == 1) {
_showSearchSheet();
} else if (value == 2) {
_showProfile(user);
setState(() => _selectedTab = 0);
}
}
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: 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']);
final notificationCount =
(summary['criticalNotificationCount'] as num? ?? 0).toInt();
return ListView(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 24),
children: [
HomeTopBar(
user: user,
notificationCount: notificationCount,
onNotifications: _openCriticalValues,
onProfile: () => _showProfile(user),
),
const SizedBox(height: 22),
SampleSearchPanel(
controller: _homeSearch,
onSearch: () => _searchAndOpenDetail(_homeSearch.text),
onScan: _scanAndSearch,
),
const SizedBox(height: 24),
const HomeSectionTitle(title: 'Menu Utama'),
const SizedBox(height: 12),
MainMenuGrid(
books: books,
onEwsTap: () => _openEws(warnings),
onInitialWorkTap: () =>
_openInitialWork(user, notificationCount),
onSpecimenTap: () =>
_openAllSpecimens(user, notificationCount),
onBookTap: _openBook,
),
const SizedBox(height: 24),
EwsSummaryPanel(
warnings: warnings,
token: widget.token,
baseUrl: widget.baseUrl,
),
const SizedBox(height: 18),
DetailSamplePanel(
onTap: () =>
_openSampleGroups(books, user, notificationCount),
total: summary['antrian_hari_ini'],
),
const SizedBox(height: 12),
],
);
},
),
),
bottomNavigationBar: FutureBuilder<Map<String, dynamic>>(
future: _future,
builder: (context, snapshot) {
final user = asMap(snapshot.data?['user']);
return NavigationBar(
selectedIndex: _selectedTab,
onDestinationSelected: (value) => _handleBottomNav(value, user),
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Beranda',
),
NavigationDestination(
icon: Icon(Icons.search),
label: 'Cari sampel',
),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: 'Profil',
),
],
);
},
),
);
}
}
class HomeTopBar extends StatelessWidget {
const HomeTopBar({
super.key,
required this.user,
required this.notificationCount,
required this.onNotifications,
required this.onProfile,
});
final Map<String, dynamic> user;
final int notificationCount;
final VoidCallback onNotifications;
final VoidCallback onProfile;
@override
Widget build(BuildContext context) {
final initials = _initials(user['nama']?.toString() ?? 'SP');
return SafeArea(
bottom: false,
child: Row(
children: [
Container(
width: 66,
height: 66,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(18),
),
child: Image.asset(kAppLogoAsset, fit: BoxFit.contain),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Mikrobiology Laboratory Information System',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: const Color(0xFF47517A),
),
),
],
),
),
Stack(
clipBehavior: Clip.none,
children: [
IconButton(
onPressed: onNotifications,
icon: const Icon(Icons.notifications_none_rounded, size: 31),
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: 8),
InkWell(
borderRadius: BorderRadius.circular(28),
onTap: onProfile,
child: Row(
children: [
Container(
width: 56,
height: 56,
alignment: Alignment.center,
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [Color(0xFF10B981), Color(0xFF06B6D4)],
),
),
child: Text(
initials,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w900,
fontSize: 20,
),
),
),
const Icon(Icons.keyboard_arrow_down_rounded),
],
),
),
],
),
);
}
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 SampleSearchPanel extends StatelessWidget {
const SampleSearchPanel({
super.key,
required this.controller,
required this.onSearch,
required this.onScan,
});
final TextEditingController controller;
final VoidCallback onSearch;
final VoidCallback onScan;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFCFE0FF)),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: const Color(0xFF2563EB).withValues(alpha: 0.05),
blurRadius: 18,
offset: const Offset(0, 10),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
Icons.manage_search_rounded,
color: Color(0xFF0B7BFF),
size: 38,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Cari sampel dengan cepat',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF080D3D),
),
),
const SizedBox(height: 3),
Text(
'Cari dan pantau status sampel',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: const Color(0xFF47517A),
),
),
],
),
),
const Icon(
Icons.keyboard_arrow_up_rounded,
color: Color(0xFF0B7BFF),
),
],
),
const SizedBox(height: 18),
Text(
'Masukkan kata kunci',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
color: const Color(0xFF47517A),
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: controller,
textInputAction: TextInputAction.search,
onSubmitted: (_) => onSearch(),
decoration: const InputDecoration(
prefixIcon: Icon(Icons.search),
hintText: 'Ketik No. Sampel, No. RM, Nama Pasien',
border: OutlineInputBorder(),
isDense: true,
),
),
),
const SizedBox(width: 10),
IconButton.outlined(
onPressed: onScan,
icon: const Icon(Icons.qr_code_scanner_rounded),
tooltip: 'Scan barcode',
),
const SizedBox(width: 10),
SizedBox(
width: 88,
child: FilledButton(
onPressed: onSearch,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('Cari'),
),
),
],
),
],
),
);
}
}
class HomeSectionTitle extends StatelessWidget {
const HomeSectionTitle({super.key, required this.title});
final String title;
@override
Widget build(BuildContext context) {
return Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF080D3D),
),
);
}
}
class MainMenuGrid extends StatelessWidget {
const MainMenuGrid({
super.key,
required this.books,
required this.onEwsTap,
required this.onInitialWorkTap,
required this.onSpecimenTap,
required this.onBookTap,
});
final List<dynamic> books;
final VoidCallback onEwsTap;
final VoidCallback onInitialWorkTap;
final VoidCallback onSpecimenTap;
final ValueChanged<Map<String, dynamic>> onBookTap;
@override
Widget build(BuildContext context) {
final items = [
_HomeMenuItem(
title: 'EWS',
subtitle: 'Early Warning Score',
icon: Icons.track_changes_rounded,
color: const Color(0xFF0B7BFF),
onTap: onEwsTap,
),
_HomeMenuItem(
title: 'Pengerjaan Awal',
subtitle: 'Validasi & Verifikasi',
icon: Icons.check_circle_outline_rounded,
color: const Color(0xFF11A85B),
onTap: onInitialWorkTap,
),
_HomeMenuItem(
title: 'Spesimen',
subtitle: 'Catatan & Register',
icon: Icons.menu_book_rounded,
color: const Color(0xFFFF6B00),
onTap: onSpecimenTap,
),
];
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: items.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 0.68,
),
itemBuilder: (context, index) => _HomeMenuCard(item: items[index]),
);
}
}
class _HomeMenuCard extends StatelessWidget {
const _HomeMenuCard({required this.item});
final _HomeMenuItem item;
@override
Widget build(BuildContext context) {
return InkWell(
borderRadius: BorderRadius.circular(14),
onTap: item.onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFE1E8F5)),
borderRadius: BorderRadius.circular(14),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
color: item.color.withValues(alpha: 0.13),
borderRadius: BorderRadius.circular(16),
),
child: Icon(item.icon, color: item.color, size: 29),
),
const SizedBox(height: 8),
Text(
item.title,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w900,
color: item.color,
height: 1.05,
),
),
const SizedBox(height: 6),
Text(
item.subtitle,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: const Color(0xFF47517A),
fontWeight: FontWeight.w600,
fontSize: 11,
height: 1.2,
),
),
],
),
),
);
}
}
class _HomeMenuItem {
const _HomeMenuItem({
required this.title,
required this.subtitle,
required this.icon,
required this.color,
required this.onTap,
});
final String title;
final String subtitle;
final IconData icon;
final Color color;
final VoidCallback onTap;
}
class EwsSummaryPanel extends StatelessWidget {
const EwsSummaryPanel({
super.key,
required this.warnings,
required this.token,
required this.baseUrl,
});
final List<dynamic> warnings;
final String token;
final String baseUrl;
@override
Widget build(BuildContext context) {
final totalTypes = warnings.length;
final rows = warnings.take(3).map((item) => asMap(item)).toList();
return Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFE1E8F5)),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 12, 14),
child: Row(
children: [
Expanded(
child: Text(
'EWS - Early Warning System',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF0B7BFF),
),
),
),
const Icon(
Icons.keyboard_arrow_up_rounded,
color: Color(0xFF0B7BFF),
),
],
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: Text(
'Ringkasan per Jenis Spesimen',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF080D3D),
),
),
),
Text(
'Total $totalTypes jenis spesimen',
style: const TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w600,
),
),
],
),
),
if (rows.isEmpty)
const EmptyPanel(text: 'Tidak ada data Early Warning.')
else
...rows.map(
(row) =>
EwsSummaryRow(group: row, token: token, baseUrl: baseUrl),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Wrap(
spacing: 16,
runSpacing: 8,
children: const [
_LegendDot(
color: Color(0xFFFF1D25),
text: 'Melewati Target (TAT > Target)',
),
_LegendDot(color: Color(0xFFFF8A00), text: 'Mendekati Target'),
],
),
),
],
),
);
}
}
class EwsSummaryRow extends StatelessWidget {
const EwsSummaryRow({
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']);
final total = (group['total'] as num? ?? items.length).toInt();
final late = _countByText(items, ['lewat', 'melewati', 'target']);
final near = total - late > 0 ? total - late : 0;
final title = group['subpoli']?.toString().isNotEmpty == true
? group['subpoli'].toString()
: 'Tanpa Subpoli';
final subtitle = items.isEmpty
? 'Belum ada sampel'
: asMap(items.first)['reques']?.toString() ?? 'Pemeriksaan aktif';
return InkWell(
onTap: () {
if (items.isEmpty) return;
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) => ListView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
children: [
Text(
title,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 10),
...items.map(
(item) => ExaminationCompactTile(
item: asMap(item),
token: token,
baseUrl: baseUrl,
),
),
],
),
);
},
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
child: Row(
children: [
_SpecimenIcon(title: title),
const SizedBox(width: 12),
Expanded(
flex: 5,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title.toUpperCase(),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF080D3D),
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 5),
Text(
subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w600,
height: 1.35,
),
),
],
),
),
_EwsCount(
label: 'Melewati',
value: late,
color: const Color(0xFFFF1D25),
),
_EwsCount(
label: 'Mendekati',
value: near,
color: const Color(0xFFFF7A00),
),
_EwsCount(
label: 'Total',
value: total,
color: const Color(0xFF47517A),
),
const Icon(Icons.chevron_right_rounded, color: Color(0xFF667095)),
],
),
),
);
}
int _countByText(List<dynamic> items, List<String> keywords) {
var count = 0;
for (final item in items) {
final text = asMap(item).values.join(' ').toLowerCase();
if (keywords.any(text.contains)) {
count += 1;
}
}
return count == 0 && items.isNotEmpty ? items.length : count;
}
}
class _SpecimenIcon extends StatelessWidget {
const _SpecimenIcon({required this.title});
final String title;
@override
Widget build(BuildContext context) {
final lower = title.toLowerCase();
final color = lower.contains('darah')
? const Color(0xFF11A85B)
: lower.contains('sputum')
? const Color(0xFF06A6D7)
: const Color(0xFF8B2BEF);
final icon = lower.contains('darah')
? Icons.water_drop_rounded
: lower.contains('sputum')
? Icons.science_rounded
: Icons.biotech_rounded;
return Container(
width: 58,
height: 58,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
shape: BoxShape.circle,
),
child: Icon(icon, color: color, size: 30),
);
}
}
class _EwsCount extends StatelessWidget {
const _EwsCount({
required this.label,
required this.value,
required this.color,
});
final String label;
final int value;
final Color color;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 66,
child: Column(
children: [
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: color,
fontSize: 10,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
'$value',
style: TextStyle(
color: color,
fontSize: 24,
fontWeight: FontWeight.w900,
),
),
],
),
);
}
}
class _LegendDot extends StatelessWidget {
const _LegendDot({required this.color, required this.text});
final Color color;
final String text;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 8),
Text(
text,
style: const TextStyle(
color: Color(0xFF47517A),
fontWeight: FontWeight.w600,
),
),
],
);
}
}
class DetailSamplePanel extends StatelessWidget {
const DetailSamplePanel({
super.key,
required this.onTap,
required this.total,
});
final VoidCallback onTap;
final Object? total;
@override
Widget build(BuildContext context) {
return InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFE1E8F5)),
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Detail Sampel',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF080D3D),
),
),
const SizedBox(height: 10),
Text(
'Pilih jenis spesimen untuk melihat daftar sampel yang mendekati atau melewati target TAT.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: const Color(0xFF47517A),
height: 1.5,
),
),
const SizedBox(height: 8),
BadgeLabel(
text: '${total ?? 0} sampel hari ini',
color: const Color(0xFF0B7BFF),
),
],
),
),
const SizedBox(width: 12),
Container(
width: 86,
height: 86,
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(18),
),
child: const Icon(
Icons.find_in_page_outlined,
color: Color(0xFF0B7BFF),
size: 52,
),
),
],
),
),
);
}
}
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: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 54,
height: 54,
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
child: Image.asset(kHospitalLogoAsset, fit: BoxFit.contain),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
kAppShortName,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w800,
),
),
const Text(
kHospitalName,
style: TextStyle(color: Colors.white70, fontSize: 12),
),
],
),
),
],
),
const SizedBox(height: 14),
Text(
user['nama']?.toString() ?? '-',
style: Theme.of(context).textTheme.titleMedium?.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,
this.detailBuilder,
});
final Map<String, dynamic> book;
final String token;
final String baseUrl;
final Widget Function(Map<String, dynamic> book)? detailBuilder;
@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: (_) =>
detailBuilder?.call(book) ??
ExaminationListScreen(
token: token,
baseUrl: baseUrl,
master: book['master'].toString(),
title: book['label']?.toString() ?? 'Pemeriksaan',
),
),
);
},
),
);
}
}