1437 lines
42 KiB
Dart
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',
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|