Files
app-grosir/lib/screens/produk_screen.dart
2026-07-23 22:12:32 +07:00

803 lines
32 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../models/product.dart';
import '../state/app_state.dart';
import '../utils/colors.dart';
import '../utils/format.dart';
import '../utils/rupiah_input.dart';
import 'scan_screen.dart';
Future<bool> confirmResellerWarning(BuildContext context, double general, double reseller) async {
if (reseller <= 0 || reseller <= general) return true;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
icon: Icon(Icons.warning_amber_rounded, color: kAccent, size: 28),
title: const Text('Peringatan'),
content: Text('Harga reseller (${formatRupiah(reseller)}) lebih tinggi dari harga umum (${formatRupiah(general)}).'),
actions: [
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: () => Navigator.pop(ctx, false),
style: OutlinedButton.styleFrom(
foregroundColor: kMuted,
side: const BorderSide(color: kBorder),
),
child: const Text('Batal'),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(backgroundColor: kAccent),
child: const Text('Tetap Simpan'),
),
),
],
),
);
return confirmed ?? false;
}
class ProdukScreen extends StatefulWidget {
const ProdukScreen({super.key});
@override
State<ProdukScreen> createState() => _ProdukScreenState();
}
class _ProdukScreenState extends State<ProdukScreen> {
final TextEditingController _search = TextEditingController();
@override
void initState() {
super.initState();
_search.addListener(() {
if (mounted) context.read<AppState>().resetPagination();
});
}
@override
void dispose() {
_search.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = context.watch<AppState>();
if (state.loading) {
return const Center(child: CircularProgressIndicator(color: kAccent));
}
final query = _search.text.toLowerCase();
final filtered = state.products
.where((p) =>
p.name.toLowerCase().contains(query) ||
p.barcode.toLowerCase().contains(query))
.toList();
final products = filtered.take(state.visibleProductCount).toList();
return RefreshIndicator(
onRefresh: () async {
context.read<AppState>().recordManualRefresh();
await context.read<AppState>().refresh();
},
color: kAccent,
child: Stack(
children: [
SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
children: [
TextField(
controller: _search,
onChanged: (_) => setState(() {}),
style: const TextStyle(color: kText),
decoration: InputDecoration(
hintText: 'Cari produk...',
prefixIcon: const Icon(Icons.search, color: kMuted),
suffixIcon: _search.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.close, color: kMuted),
onPressed: () {
_search.clear();
setState(() {});
},
)
: null,
),
),
const SizedBox(height: 12),
Row(
children: [
Text(
'Daftar Produk',
style: const TextStyle(
fontSize: 15, fontWeight: FontWeight.w700),
),
const Spacer(),
FilledButton.icon(
onPressed: () => _showAddForm(context, state),
icon: const Icon(Icons.add, size: 18),
label: const Text('Tambah Produk'),
style: FilledButton.styleFrom(
backgroundColor: kAccent,
foregroundColor: const Color(0xFF06210F),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
visualDensity: VisualDensity.compact,
),
),
],
),
const SizedBox(height: 12),
if (products.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.inventory_2_outlined, size: 48, color: kMuted),
SizedBox(height: 8),
Text('Belum ada produk', style: TextStyle(color: kMuted)),
],
),
),
)
else
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: products.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, i) =>
_ProductRow(p: products[i], state: state),
),
if (state.hasMoreProducts)
Padding(
padding: const EdgeInsets.only(top: 12),
child: FilledButton.icon(
onPressed: () => state.loadMoreProducts(),
icon: const Icon(Icons.expand_more, size: 18),
label: Text('Muat lebih banyak (${state.products.length - state.visibleProductCount} tersisa)'),
),
),
],
),
),
],
),
);
}
}
void _showAddForm(BuildContext context, AppState state) {
final name = TextEditingController();
final barcode = TextEditingController();
final price = TextEditingController();
final priceReseller = TextEditingController();
final stock = TextEditingController();
final minStockCtrl = TextEditingController(text: '5');
final hargaPokokCtrl = TextEditingController();
final focusNodes = <FocusNode>[
FocusNode(),
FocusNode(),
FocusNode(),
FocusNode(),
FocusNode(),
FocusNode(),
FocusNode(),
];
bool _listenersAdded = false;
final _sheetController = DraggableScrollableController();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) => DraggableScrollableSheet(
controller: _sheetController,
initialChildSize: 0.65,
minChildSize: 0.5,
maxChildSize: 0.92,
builder: (_, scrollController) {
if (!_listenersAdded) {
_listenersAdded = true;
for (final node in focusNodes) {
node.addListener(() {
if (node.hasFocus && context.mounted) {
_sheetController.animateTo(
0.92,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
Future.delayed(const Duration(milliseconds: 350), () {
if (node.context != null && node.context!.mounted) {
Scrollable.ensureVisible(
node.context!,
duration: const Duration(milliseconds: 250),
alignment: 0.0,
);
}
});
}
});
}
}
return Container(
decoration: const BoxDecoration(
color: kSurface,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
border: Border(top: BorderSide(color: kBorder)),
),
child: Column(
children: [
Container(
margin: const EdgeInsets.only(top: 12),
width: 40,
height: 4,
decoration: BoxDecoration(
color: kBorder,
borderRadius: BorderRadius.circular(4),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
children: [
const Icon(Icons.add_box_outlined, color: kAccent),
const SizedBox(width: 8),
const Text('Tambah Produk',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w700)),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, color: kMuted),
onPressed: () => Navigator.pop(sheetContext),
tooltip: 'Tutup',
),
],
),
),
const Divider(height: 12),
Expanded(
child: SingleChildScrollView(
controller: scrollController,
padding: EdgeInsets.fromLTRB(16, 8, 16, MediaQuery.of(context).viewInsets.bottom + 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: barcode,
focusNode: focusNodes[0],
decoration: InputDecoration(
labelText: 'Barcode / No. Produk',
prefixIcon: const Icon(Icons.qr_code_2_outlined),
suffixIcon: IconButton(
icon: const Icon(Icons.qr_code_scanner_outlined, color: kAccent),
onPressed: () async {
final code = await Navigator.push<String>(
context,
MaterialPageRoute(
builder: (_) => const ScanScreen(
title: 'Scan Barcode Produk',
),
),
);
if (code != null && context.mounted) {
barcode.text = code;
}
},
tooltip: 'Scan barcode',
)),
),
const SizedBox(height: 10),
TextField(
controller: name,
focusNode: focusNodes[1],
decoration: const InputDecoration(
labelText: 'Nama produk',
prefixIcon: Icon(Icons.label_outline)),
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: TextField(
controller: price,
focusNode: focusNodes[2],
keyboardType: TextInputType.number,
inputFormatters: [RupiahInputFormatter()],
decoration: const InputDecoration(
labelText: 'Harga Umum',
prefixText: 'Rp ',
),
),
),
const SizedBox(width: 10),
Expanded(
child: TextField(
controller: priceReseller,
focusNode: focusNodes[3],
keyboardType: TextInputType.number,
inputFormatters: [RupiahInputFormatter()],
decoration: const InputDecoration(
labelText: 'Harga Reseller',
prefixText: 'Rp ',
),
),
),
],
),
const SizedBox(height: 10),
TextField(
controller: stock,
focusNode: focusNodes[4],
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Stok awal',
prefixIcon: Icon(Icons.numbers),
),
),
const SizedBox(height: 10),
TextField(
controller: hargaPokokCtrl,
focusNode: focusNodes[5],
keyboardType: TextInputType.number,
inputFormatters: [RupiahInputFormatter()],
decoration: const InputDecoration(
labelText: 'Harga Pokok (modal)',
prefixText: 'Rp ',
helperText: 'Kosongkan jika tidak diketahui',
),
),
const SizedBox(height: 10),
TextField(
controller: minStockCtrl,
focusNode: focusNodes[6],
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Stok minimum (warning)',
prefixIcon: Icon(Icons.warning_outlined),
helperText: 'Default: 5',
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () async {
final n = name.text.trim();
final bc = barcode.text.trim();
final pr = (RupiahInputFormatter.getRawValue(price.text) ?? 0)
.toDouble();
final prR =
(RupiahInputFormatter.getRawValue(priceReseller.text) ?? pr)
.toDouble();
final st = int.tryParse(stock.text) ?? 0;
final ms = int.tryParse(minStockCtrl.text) ?? 5;
final hp = (RupiahInputFormatter.getRawValue(hargaPokokCtrl.text) ?? 0)
.toDouble();
if (n.isEmpty || pr <= 0) return;
if (!await confirmResellerWarning(context, pr, prR)) return;
final err = await state.addProduct(n, bc, pr, prR, st, ms, hp);
if (!context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
if (err == null) {
name.clear();
barcode.clear();
price.clear();
priceReseller.clear();
stock.clear();
minStockCtrl.clear();
hargaPokokCtrl.clear();
messenger.showSnackBar(
const SnackBar(
content: Text('Produk berhasil ditambahkan'),
backgroundColor: kSuccess,
),
);
Navigator.pop(sheetContext);
} else {
messenger.showSnackBar(
SnackBar(
content: Text('Gagal menambah produk: '),
backgroundColor: kDanger,
),
);
}
},
icon: const Icon(Icons.add),
label: const Text('Tambah Produk'),
),
),
],
),
),
),
],
),
);
},
),
);
}
void _showEditDialog(BuildContext context, AppState state, Product product) {
final name = TextEditingController(text: product.name);
final barcode = TextEditingController(text: product.barcode);
final price = TextEditingController(
text: RupiahInputFormatter.formatNumber(product.price.toInt()));
final priceReseller = TextEditingController(
text: RupiahInputFormatter.formatNumber(product.priceReseller.toInt()));
final stock = TextEditingController(text: product.stock.toString());
final minStockCtrl = TextEditingController(text: product.minStock.toString());
final hargaPokokCtrl = TextEditingController(
text: product.hargaPokok > 0
? RupiahInputFormatter.formatNumber(product.hargaPokok.toInt())
: '');
final focusNodes = <FocusNode>[
FocusNode(),
FocusNode(),
FocusNode(),
FocusNode(),
FocusNode(),
FocusNode(),
FocusNode(),
];
bool _listenersAdded = false;
final _sheetController = DraggableScrollableController();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) {
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
return DraggableScrollableSheet(
controller: _sheetController,
initialChildSize: 0.65,
minChildSize: 0.5,
maxChildSize: 0.92,
builder: (_, scrollController) {
if (!_listenersAdded) {
_listenersAdded = true;
for (final node in focusNodes) {
node.addListener(() {
if (node.hasFocus && context.mounted) {
_sheetController.animateTo(
0.92,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
Future.delayed(const Duration(milliseconds: 350), () {
if (node.context != null && node.context!.mounted) {
Scrollable.ensureVisible(
node.context!,
duration: const Duration(milliseconds: 250),
alignment: 0.0,
);
}
});
}
});
}
}
return Container(
decoration: const BoxDecoration(
color: kSurface,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
border: Border(top: BorderSide(color: kBorder)),
),
child: Column(
children: [
Container(
margin: const EdgeInsets.only(top: 12),
width: 40,
height: 4,
decoration: BoxDecoration(
color: kBorder,
borderRadius: BorderRadius.circular(4),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
children: [
const Icon(Icons.edit_outlined, color: kAccent),
const SizedBox(width: 8),
const Text('Edit Produk',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, color: kMuted),
onPressed: () => Navigator.pop(sheetContext),
tooltip: 'Tutup',
),
],
),
),
const Divider(height: 12),
Expanded(
child: SingleChildScrollView(
controller: scrollController,
padding: EdgeInsets.fromLTRB(16, 8, 16, 16 + bottomInset),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: barcode,
focusNode: focusNodes[0],
decoration: const InputDecoration(
labelText: 'Barcode / No. Produk',
prefixIcon: Icon(Icons.qr_code_2_outlined)),
),
const SizedBox(height: 10),
TextField(
controller: name,
focusNode: focusNodes[1],
decoration: const InputDecoration(labelText: 'Nama'),
),
const SizedBox(height: 10),
TextField(
controller: price,
focusNode: focusNodes[2],
keyboardType: TextInputType.number,
inputFormatters: [RupiahInputFormatter()],
decoration: const InputDecoration(labelText: 'Harga Umum', prefixText: 'Rp '),
),
const SizedBox(height: 10),
TextField(
controller: priceReseller,
focusNode: focusNodes[3],
keyboardType: TextInputType.number,
inputFormatters: [RupiahInputFormatter()],
decoration: const InputDecoration(labelText: 'Harga Reseller', prefixText: 'Rp '),
),
const SizedBox(height: 10),
TextField(
controller: hargaPokokCtrl,
focusNode: focusNodes[4],
keyboardType: TextInputType.number,
inputFormatters: [RupiahInputFormatter()],
decoration: const InputDecoration(
labelText: 'Harga Pokok (modal)',
prefixText: 'Rp ',
helperText: 'Kosongkan jika tidak diketahui',
),
),
const SizedBox(height: 10),
TextField(
controller: stock,
focusNode: focusNodes[5],
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Stok'),
),
const SizedBox(height: 10),
TextField(
controller: minStockCtrl,
focusNode: focusNodes[6],
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Stok minimum (warning)',
helperText: 'Default: 5',
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () async {
final n = name.text.trim();
final bc = barcode.text.trim();
final pr = (RupiahInputFormatter.getRawValue(price.text) ?? 0)
.toDouble();
final prR =
(RupiahInputFormatter.getRawValue(priceReseller.text) ?? pr)
.toDouble();
final st = int.tryParse(stock.text) ?? 0;
final ms = int.tryParse(minStockCtrl.text) ?? 5;
final hp = (RupiahInputFormatter.getRawValue(hargaPokokCtrl.text) ?? 0)
.toDouble();
if (n.isEmpty || pr <= 0) return;
if (!await confirmResellerWarning(context, pr, prR)) return;
final err = await state.updateProduct(product.id, n, bc, pr, prR, st, ms, hp);
if (!context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
if (err == null) {
messenger.showSnackBar(
const SnackBar(
content: Text('Produk berhasil diperbarui'),
backgroundColor: kSuccess,
),
);
Navigator.pop(sheetContext);
} else {
messenger.showSnackBar(
SnackBar(
content: Text('Gagal memperbarui produk: '),
backgroundColor: kDanger,
),
);
}
},
icon: const Icon(Icons.save),
label: const Text('Simpan'),
),
),
],
),
),
),
],
),
);
},
);
},
);
}
class _ProductRow extends StatelessWidget {
final Product p;
final AppState state;
const _ProductRow({required this.p, required this.state});
@override
Widget build(BuildContext context) {
final out = p.stock <= 0;
final lowStock = p.stock > 0 && p.stock <= p.minStock;
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
CircleAvatar(
radius: 22,
backgroundColor: kAccent.withValues(alpha: 0.15),
child: Text(
p.name.isNotEmpty ? p.name[0].toUpperCase() : '?',
style:
TextStyle(color: kAccent, fontWeight: FontWeight.w700),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(p.name,
style: const TextStyle(
fontSize: 15, fontWeight: FontWeight.w600)),
if (p.barcode.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
p.barcode,
style: const TextStyle(
fontSize: 11, color: kMuted, fontFamily: 'monospace'),
),
],
const SizedBox(height: 4),
Wrap(
spacing: 10,
runSpacing: 6,
children: [
_Tag(formatRupiah(p.price), kSuccess),
if (p.priceReseller > 0)
_Tag('Rsl ${formatRupiah(p.priceReseller)}', kAccent),
if (p.hargaPokok > 0)
_Tag('Modal ${formatRupiah(p.hargaPokok)}', kAccent),
if (out)
_Tag('Stok habis', kDanger)
else if (lowStock)
_Tag('Stok rendah (${p.stock}/${p.minStock})', kAccent)
else
_Tag('Stok ${p.stock}', kMuted),
],
),
],
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit_outlined, size: 20),
color: kAccent,
onPressed: () => _showEditDialog(context, state, p),
),
IconButton(
icon: const Icon(Icons.delete_outline, size: 20),
color: kDanger,
onPressed: () async {
HapticFeedback.mediumImpact();
final messenger = ScaffoldMessenger.of(context);
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
icon: Icon(Icons.delete_outline, color: kDanger, size: 28),
title: const Text('Hapus Produk?'),
content: Text('Yakin ingin menghapus "${p.name}"?'),
actions: [
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: () => Navigator.pop(ctx, false),
style: OutlinedButton.styleFrom(
foregroundColor: kMuted,
side: const BorderSide(color: kBorder),
),
child: const Text('Batal'),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: kDanger,
),
child: const Text('Hapus'),
),
),
],
),
);
if (confirm == true && context.mounted) {
final err = await state.deleteProduct(p.id);
if (err == null) {
messenger.showSnackBar(
const SnackBar(
content: Text('Produk berhasil dihapus'),
backgroundColor: kSuccess,
),
);
} else {
messenger.showSnackBar(
SnackBar(
content: Text('Gagal menghapus produk: $err'),
backgroundColor: kDanger,
),
);
}
}
},
),
],
),
],
),
),
);
}
}
class _Tag extends StatelessWidget {
final String label;
final Color color;
const _Tag(this.label, this.color);
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Text(label,
style: TextStyle(
fontSize: 11, fontWeight: FontWeight.w600, color: color)),
);
}
}