502 lines
22 KiB
Dart
502 lines
22 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_slidable/flutter_slidable.dart';
|
|
import 'package:inventory_system/services/item_service.dart';
|
|
import 'package:inventory_system/screens/admin/item/item_form.dart';
|
|
import 'package:inventory_system/screens/bottom_nav_bar.dart';
|
|
import 'package:inventory_system/screens/nav_bar.dart';
|
|
import 'package:inventory_system/screens/title_bar.dart';
|
|
import 'package:qr_flutter/qr_flutter.dart';
|
|
import 'package:pdf/pdf.dart';
|
|
import 'package:pdf/widgets.dart' as pw;
|
|
import 'package:printing/printing.dart';
|
|
|
|
class ItemScreen extends StatefulWidget {
|
|
const ItemScreen({super.key});
|
|
|
|
@override
|
|
State<ItemScreen> createState() => _ItemScreenState();
|
|
}
|
|
|
|
class _ItemScreenState extends State<ItemScreen> {
|
|
int _selectedIndex = 0;
|
|
final ItemService _itemService = ItemService();
|
|
late Future<List<dynamic>> _itemsFuture;
|
|
|
|
String _selectedFilter = 'All';
|
|
final List<String> _filters = ['All', 'Asset', 'Disposable', 'Part'];
|
|
|
|
List<dynamic> _allItems = [];
|
|
List<dynamic> _filteredItems = [];
|
|
final TextEditingController _searchController = TextEditingController();
|
|
int? _expandedItemId;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_itemsFuture = _fetchItems();
|
|
_searchController.addListener(_filterAndSortItems);
|
|
}
|
|
|
|
Future<List<dynamic>> _fetchItems() async {
|
|
try {
|
|
final items = await _itemService.fetchItems();
|
|
if (mounted) {
|
|
setState(() {
|
|
_allItems = items;
|
|
_filterAndSortItems();
|
|
});
|
|
}
|
|
return items;
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Failed to load items: $e'), backgroundColor: Colors.red),
|
|
);
|
|
}
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_searchController.removeListener(_filterAndSortItems);
|
|
_searchController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _filterAndSortItems() {
|
|
final query = _searchController.text.toLowerCase();
|
|
|
|
final filtered = _allItems.where((item) {
|
|
final name = item['productName']?.toString().toLowerCase() ?? '';
|
|
final serial = item['serialNumber']?.toString().toLowerCase() ?? '';
|
|
final category = item['category']?.toString().toLowerCase() ?? '';
|
|
final uniqueId = item['uniqueID']?.toString().toLowerCase() ?? '';
|
|
|
|
final matchesSearch = name.contains(query) || serial.contains(query) || uniqueId.contains(query);
|
|
final matchesFilter = _selectedFilter == 'All' || category == _selectedFilter.toLowerCase();
|
|
|
|
return matchesSearch && matchesFilter;
|
|
}).toList();
|
|
|
|
// Sort the filtered list by uniqueID
|
|
filtered.sort((a, b) {
|
|
final idA = a['uniqueID'] as String? ?? '';
|
|
final idB = b['uniqueID'] as String? ?? '';
|
|
return idA.compareTo(idB);
|
|
});
|
|
|
|
setState(() {
|
|
_filteredItems = filtered;
|
|
_expandedItemId = null;
|
|
});
|
|
}
|
|
|
|
void _deleteItem(int itemId) async {
|
|
try {
|
|
await _itemService.deleteItem(itemId);
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Item deleted successfully'), backgroundColor: Colors.green),
|
|
);
|
|
_fetchItems();
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Failed to delete item: $e'), backgroundColor: Colors.red),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _confirmDelete(int itemId, String itemName) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Delete Item'),
|
|
content: Text('Are you sure you want to delete $itemName?'),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
_deleteItem(itemId);
|
|
},
|
|
child: const Text('Delete', style: TextStyle(color: Colors.red)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _printQrInfo(Map<String, dynamic> item) async {
|
|
final doc = pw.Document();
|
|
|
|
doc.addPage(
|
|
pw.Page(
|
|
pageFormat: PdfPageFormat.a4,
|
|
build: (pw.Context context) {
|
|
return pw.Column(
|
|
mainAxisSize: pw.MainAxisSize.min,
|
|
children: [
|
|
pw.Row(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
children: [
|
|
pw.Column(
|
|
children: [
|
|
pw.BarcodeWidget(
|
|
barcode: pw.Barcode.qrCode(),
|
|
data: item['uniqueID'] ?? '',
|
|
width: 100,
|
|
height: 100,
|
|
),
|
|
pw.SizedBox(height: 8),
|
|
pw.Text(
|
|
item['uniqueID'] ?? '',
|
|
style: const pw.TextStyle(fontSize: 12), // monospace not strictly required for PDF unless font loaded
|
|
),
|
|
],
|
|
),
|
|
pw.SizedBox(width: 24),
|
|
pw.Expanded(
|
|
child: pw.Column(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
children: [
|
|
pw.Text(item['currentStore'] ?? 'N/A', style: const pw.TextStyle(fontSize: 16)),
|
|
pw.SizedBox(height: 8),
|
|
pw.Text(item['productShortName'] ?? item['productName'] ?? '', style: const pw.TextStyle(fontSize: 16)),
|
|
pw.SizedBox(height: 8),
|
|
pw.Text(item['serialNumber'] ?? '', style: const pw.TextStyle(fontSize: 16)),
|
|
pw.SizedBox(height: 8),
|
|
pw.Text(item['partNumber'] ?? '', style: const pw.TextStyle(fontSize: 16)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
|
|
await Printing.layoutPdf(
|
|
onLayout: (PdfPageFormat format) async => doc.save(),
|
|
name: '${item['uniqueID']}_QR.pdf',
|
|
);
|
|
}
|
|
|
|
void _showPrintDialog(Map<String, dynamic> item) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) {
|
|
return Dialog(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 48, 24, 24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Column(
|
|
children: [
|
|
QrImageView(
|
|
data: item['uniqueID'] ?? '',
|
|
version: QrVersions.auto,
|
|
size: 100.0,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
item['uniqueID'] ?? '',
|
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(width: 24),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(item['currentStore'] ?? 'N/A', style: const TextStyle(fontFamily: 'monospace', fontSize: 16)),
|
|
const SizedBox(height: 8),
|
|
Text(item['productShortName'] ?? item['productName'] ?? '', style: const TextStyle(fontFamily: 'monospace', fontSize: 16)),
|
|
const SizedBox(height: 8),
|
|
Text(item['serialNumber'] ?? '', style: const TextStyle(fontFamily: 'monospace', fontSize: 16)),
|
|
const SizedBox(height: 8),
|
|
Text(item['partNumber'] ?? '', style: const TextStyle(fontFamily: 'monospace', fontSize: 16)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton(
|
|
onPressed: () => _printQrInfo(item),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.grey.shade200,
|
|
foregroundColor: Colors.black87,
|
|
elevation: 0,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8), side: BorderSide(color: Colors.grey.shade400)),
|
|
),
|
|
child: const Text('Print QR Info'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Positioned(
|
|
top: 8,
|
|
right: 8,
|
|
child: IconButton(
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: const Color(0xFFF5F5F7),
|
|
appBar: const TitleBar(title: 'Item'),
|
|
drawer: const NavBar(isAdmin: true, selectedScreen: AppScreen.item),
|
|
body: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
ElevatedButton.icon(
|
|
onPressed: () {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (context) => const ItemFormScreen()),
|
|
).then((success) {
|
|
if (success == true) _fetchItems();
|
|
});
|
|
},
|
|
icon: const Icon(Icons.add, size: 18),
|
|
label: const Text('Add Item'),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.purple.shade100,
|
|
foregroundColor: Colors.black87,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _searchController,
|
|
decoration: InputDecoration(
|
|
hintText: 'Search',
|
|
hintStyle: TextStyle(color: Colors.grey.shade400),
|
|
prefixIcon: const Icon(Icons.search),
|
|
filled: true,
|
|
fillColor: Colors.white,
|
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
SizedBox(
|
|
height: 36,
|
|
child: ListView.separated(
|
|
scrollDirection: Axis.horizontal,
|
|
itemCount: _filters.length,
|
|
separatorBuilder: (context, index) => const SizedBox(width: 8),
|
|
itemBuilder: (context, index) {
|
|
final filter = _filters[index];
|
|
final isSelected = _selectedFilter == filter;
|
|
return ChoiceChip(
|
|
label: Text(filter),
|
|
selected: isSelected,
|
|
onSelected: (selected) {
|
|
if (selected) {
|
|
setState(() {
|
|
_selectedFilter = filter;
|
|
_filterAndSortItems();
|
|
});
|
|
}
|
|
},
|
|
backgroundColor: Colors.white,
|
|
selectedColor: Colors.purple.shade100,
|
|
labelStyle: TextStyle(color: isSelected ? Colors.purple.shade900 : Colors.black54, fontWeight: FontWeight.bold),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18), side: BorderSide(color: isSelected ? Colors.purple.shade100 : Colors.grey.shade300)),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Expanded(
|
|
child: RefreshIndicator(
|
|
onRefresh: _fetchItems,
|
|
child: FutureBuilder<List<dynamic>>(
|
|
future: _itemsFuture,
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.waiting && _allItems.isEmpty) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
if (snapshot.hasError && _allItems.isEmpty) {
|
|
return Center(child: Text('Error: ${snapshot.error}'));
|
|
}
|
|
if (_filteredItems.isEmpty) {
|
|
return const Center(child: Text('No items found.'));
|
|
}
|
|
|
|
return ListView.builder(
|
|
itemCount: _filteredItems.length,
|
|
itemBuilder: (context, index) {
|
|
final item = _filteredItems[index];
|
|
return _buildItemWidget(item);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
bottomNavigationBar: BottomNavBar(selectedIndex: _selectedIndex, onItemTapped: (index) {
|
|
if (index == 0) { Navigator.pop(context); } else if (index == 1) { if (mounted) { Navigator.pushNamed(context, '/scan'); } } else { setState(() { _selectedIndex = index; }); }
|
|
}),
|
|
);
|
|
}
|
|
|
|
Widget _buildItemWidget(Map<String, dynamic> item) {
|
|
final isExpanded = _expandedItemId == item['itemID'];
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Slidable(
|
|
key: Key(item['itemID'].toString()),
|
|
endActionPane: ActionPane(
|
|
motion: const ScrollMotion(),
|
|
extentRatio: 0.45,
|
|
children: [
|
|
SlidableAction(onPressed: (context) => Navigator.push(context, MaterialPageRoute(builder: (context) => ItemFormScreen(item: item))).then((success) => {if(success == true) _fetchItems()}), backgroundColor: Colors.blue, foregroundColor: Colors.white, icon: Icons.edit, label: 'Edit'),
|
|
SlidableAction(onPressed: (context) => _confirmDelete(item['itemID'], item['productName']), backgroundColor: Colors.red, foregroundColor: Colors.white, icon: Icons.delete, label: 'Delete', borderRadius: const BorderRadius.horizontal(right: Radius.circular(16))),
|
|
],
|
|
),
|
|
child: Container(
|
|
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), border: Border.all(color: Colors.grey.shade300, width: 1)),
|
|
child: Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Column(
|
|
children: [
|
|
Container(
|
|
width: 80, height: 80, padding: const EdgeInsets.all(4), decoration: BoxDecoration(border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(8)),
|
|
child: QrImageView(data: item['uniqueID'] ?? '', version: QrVersions.auto, size: 72),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(item['uniqueID'] ?? '', style: TextStyle(fontSize: 12, color: Colors.black)),
|
|
],
|
|
),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(item['productName'] ?? 'N/A', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
|
const SizedBox(height: 8),
|
|
Text('Serial Number: ${item['serialNumber'] ?? 'N/A'}', style: TextStyle(fontSize: 13, color: Colors.grey.shade600)),
|
|
const SizedBox(height: 4),
|
|
Text('Part Number: ${item['partNumber'] ?? 'N/A'}', style: TextStyle(fontSize: 13, color: Colors.grey.shade600)),
|
|
],
|
|
),
|
|
),
|
|
ElevatedButton.icon(onPressed: () => _showPrintDialog(item), icon: const Icon(Icons.print, size: 16), label: const Text('Print'), style: ElevatedButton.styleFrom(backgroundColor: Colors.green.shade400, foregroundColor: Colors.white, elevation: 0, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)))),
|
|
],
|
|
),
|
|
),
|
|
InkWell(
|
|
onTap: () => setState(() => _expandedItemId = isExpanded ? null : item['itemID']),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 12), decoration: BoxDecoration(border: Border(top: BorderSide(color: Colors.grey.shade200))),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(isExpanded ? 'Less Details' : 'More Details', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.blue.shade600)),
|
|
const SizedBox(width: 4),
|
|
Icon(isExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, color: Colors.blue.shade600, size: 20),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
AnimatedSize(
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.fastOutSlowIn,
|
|
child: isExpanded
|
|
? Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(color: Colors.grey.shade50, border: Border(top: BorderSide(color: Colors.grey.shade200))),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(child: _buildDetailItem('Category', item['category'] ?? 'N/A')),
|
|
const SizedBox(width: 16),
|
|
Expanded(child: _buildDetailItem('Quantity', item['quantity'].toString())),
|
|
const SizedBox(width: 16),
|
|
Expanded(child: _buildDetailItem('Price', 'RM${item['convertPrice']?.toString() ?? '0.00'}'))]),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(child: _buildDetailItem('Supplier', item['supplier'] ?? 'N/A')),
|
|
const SizedBox(width: 16),
|
|
Expanded(child: _buildDetailItem('Purchase Date', item['purchaseDate'] ?? 'N/A')),
|
|
const SizedBox(width: 16),
|
|
Expanded(child: _buildDetailItem('Warranty Until', item['endWDate'] ?? 'N/A'))]),
|
|
const SizedBox(height: 12),
|
|
const Text('Location', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.black54)),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(child: _buildDetailItem('User', item['currentUser'] ?? 'N/A')),
|
|
const SizedBox(width: 16),
|
|
Expanded(child: _buildDetailItem('Store', item['currentStore'] ?? 'N/A')),
|
|
const SizedBox(width: 16),
|
|
Expanded(child: _buildDetailItem('Station', item['currentStation'] ?? 'N/A')),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: const SizedBox.shrink(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDetailRow(String label, String value) => Row(crossAxisAlignment: CrossAxisAlignment.start, children: [SizedBox(width: 60, child: Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: Colors.grey.shade700))), Expanded(child: Text(value, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.black87)))]);
|
|
|
|
Widget _buildDetailItem(String label, String value) {
|
|
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(label, style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w500)), if (value.isNotEmpty) const SizedBox(height: 4), if (value.isNotEmpty) Text(value, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.black87))]);
|
|
}
|
|
}
|