diff --git a/lib/screens/kasir_screen.dart b/lib/screens/kasir_screen.dart index 7e4fe74..f2dc7da 100644 --- a/lib/screens/kasir_screen.dart +++ b/lib/screens/kasir_screen.dart @@ -23,14 +23,26 @@ class KasirScreen extends StatefulWidget { class _KasirScreenState extends State { final TextEditingController _search = TextEditingController(); Timer? _searchDebounce; + bool _isGrid = false; // default: list (foto lebih jelas) + String? _lastAddedId; + Timer? _lastAddedTimer; @override void dispose() { _searchDebounce?.cancel(); + _lastAddedTimer?.cancel(); _search.dispose(); super.dispose(); } + void _markAdded(String id) { + _lastAddedTimer?.cancel(); + setState(() => _lastAddedId = id); + _lastAddedTimer = Timer(const Duration(milliseconds: 900), () { + if (mounted) setState(() => _lastAddedId = null); + }); + } + @override Widget build(BuildContext context) { final state = context.watch(); @@ -49,7 +61,10 @@ class _KasirScreenState extends State { children: [ Padding( padding: const EdgeInsets.fromLTRB(12, 12, 12, 6), - child: TextField( + child: Row( + children: [ + Expanded( + child: TextField( controller: _search, onChanged: (_) { _searchDebounce?.cancel(); @@ -71,6 +86,61 @@ class _KasirScreenState extends State { ) : null, ), + ), + ), + const SizedBox(width: 8), + Stack( + children: [ + IconButton( + tooltip: 'Buka keranjang', + style: IconButton.styleFrom( + backgroundColor: kSurface, + foregroundColor: kAccent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: kBorder), + ), + ), + icon: const Icon(Icons.shopping_cart), + onPressed: () => _openCartSheet(context, state), + ), + if (state.cart.isNotEmpty) + Positioned( + right: 0, + top: 0, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: kDanger, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '${state.cart.fold(0, (s, c) => s + c.qty)}', + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + const SizedBox(width: 8), + IconButton( + tooltip: _isGrid ? 'Tampilan list' : 'Tampilan grid', + style: IconButton.styleFrom( + backgroundColor: kSurface, + foregroundColor: kAccent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: kBorder), + ), + ), + icon: Icon(_isGrid ? Icons.view_list : Icons.grid_view), + onPressed: () => setState(() => _isGrid = !_isGrid), + ), + ], ), ), Padding( @@ -218,52 +288,54 @@ class _KasirScreenState extends State { ), ), Expanded( - child: products.isEmpty - ? Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: const [ - Icon(Icons.search_off, size: 48, color: kMuted), - SizedBox(height: 8), - Text('Produk tidak ditemukan', - style: TextStyle(color: kMuted)), - ], + child: _isGrid + ? GridView.builder( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 12), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + mainAxisExtent: 168, ), - ) - : RefreshIndicator( - onRefresh: () async { - context.read().recordManualRefresh(); - await context.read().refresh(); - }, - color: kAccent, - child: GridView.builder( - padding: const EdgeInsets.fromLTRB(12, 6, 12, 12), - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: 1.1, - ), - itemCount: products.length, - itemBuilder: (context, i) => _ProductCard( - product: products[i], - displayPrice: - isReseller ? products[i].priceReseller : products[i].price, - isReseller: isReseller, + itemCount: products.length, + itemBuilder: (context, i) { + final p = products[i]; + // Hijau sesaat hanya untuk produk yang baru ditap, + // BUKAN semua isi keranjang — tap selalu tambah qty. + final highlight = _lastAddedId == p.id; + return _KasirCard( + key: ValueKey(p.id), + product: p, + price: isReseller ? p.priceReseller : p.price, + inCart: highlight, onTap: () { - HapticFeedback.lightImpact(); - state.addToCart(products[i]); + state.addToCart(p); + _markAdded(p.id); }, - ), - ), + ); + }, + ) + : ListView.separated( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 12), + itemCount: products.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, i) { + final p = products[i]; + final highlight = _lastAddedId == p.id; + return _KasirListTile( + key: ValueKey(p.id), + product: p, + price: isReseller ? p.priceReseller : p.price, + inCart: highlight, + onTap: () { + state.addToCart(p); + _markAdded(p.id); + }, + ); + }, ), ), - if (state.cart.isNotEmpty) - _CartBar( - onTap: () => _openCartSheet(context, state), - itemCount: state.cart.fold(0, (s, c) => s + c.qty), - ), ], ); } @@ -1139,7 +1211,7 @@ class _CartSheetContent extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), child: Row( children: [ - _KasirAvatar(product: line.product, radius: 18), + _KasirAvatar(product: line.product, size: 36, radius: 18), const SizedBox(width: 12), Expanded( child: Column( @@ -1437,6 +1509,245 @@ class _ReceiptRow extends StatelessWidget { } } +// Avatar foto produk kasir (56px default) atau huruf inisial. +class _KasirAvatar extends StatelessWidget { + final Product product; + final double size; + final double? radius; + const _KasirAvatar({required this.product, this.size = 56, this.radius}); + + @override + Widget build(BuildContext context) { + final url = productImageUrl(product.imagePath); + final r = radius ?? 12; + if (url != null) { + return ClipRRect( + borderRadius: BorderRadius.circular(r), + child: Image.network( + url, + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + _KasirInitial(product: product, size: size, radius: r), + ), + ); + } + return _KasirInitial(product: product, size: size, radius: r); + } +} + +class _KasirInitial extends StatelessWidget { + final Product product; + final double size; + final double radius; + const _KasirInitial( + {required this.product, required this.size, this.radius = 12}); + + @override + Widget build(BuildContext context) { + final initial = + product.name.isNotEmpty ? product.name[0].toUpperCase() : '?'; + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: productAccent(product.name).withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(radius), + ), + alignment: Alignment.center, + child: Text( + initial, + style: TextStyle( + color: productAccent(product.name), + fontWeight: FontWeight.bold, + fontSize: size * 0.42, + ), + ), + ); + } +} + +// Kartu grid kasir. +class _KasirCard extends StatelessWidget { + final Product product; + final double price; + final bool inCart; + final VoidCallback onTap; + + const _KasirCard({ + super.key, + required this.product, + required this.price, + required this.inCart, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: inCart ? kSuccess.withValues(alpha: 0.12) : kSurface, + borderRadius: BorderRadius.circular(kRadius), + border: Border.all( + color: inCart ? kSuccess : kBorder, width: inCart ? 1.5 : 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _KasirAvatar(product: product, size: 40), + const Spacer(), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: product.stock > 0 + ? kSuccess.withValues(alpha: 0.15) + : kDanger.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + 'Stok ${product.stock}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: product.stock > 0 ? kSuccess : kDanger, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + product.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: kText, fontWeight: FontWeight.w600, fontSize: 13), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: kAccent.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + formatRupiah(price), + style: const TextStyle( + color: kAccent, + fontWeight: FontWeight.bold, + fontSize: 13), + ), + ), + ], + ), + ), + ); + } +} + +// Baris list kasir: foto 56px + nama/harga/stok + tombol tambah. +class _KasirListTile extends StatelessWidget { + final Product product; + final double price; + final bool inCart; + final VoidCallback onTap; + + const _KasirListTile({ + super.key, + required this.product, + required this.price, + required this.inCart, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: inCart ? kSuccess.withValues(alpha: 0.12) : kSurface, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: inCart ? kSuccess : kBorder, width: inCart ? 1.5 : 1), + ), + child: Row( + children: [ + _KasirAvatar(product: product, size: 56), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + product.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: kText, + fontWeight: FontWeight.w600, + fontSize: 14), + ), + const SizedBox(height: 4), + Text( + formatRupiah(price), + style: const TextStyle( + color: kAccent, + fontWeight: FontWeight.bold, + fontSize: 14), + ), + const SizedBox(height: 4), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: product.stock > 0 + ? kSuccess.withValues(alpha: 0.15) + : kDanger.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + 'Stok ${product.stock}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: product.stock > 0 ? kSuccess : kDanger, + ), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: inCart + ? kSuccess.withValues(alpha: 0.2) + : kAccent.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + inCart ? Icons.check : Icons.add, + color: inCart ? kSuccess : kAccent, + ), + ), + ], + ), + ), + ); + } +} + class _ProductCard extends StatelessWidget { final Product product; final double displayPrice; @@ -1479,7 +1790,7 @@ class _ProductCard extends StatelessWidget { children: [ Row( children: [ - _KasirAvatar(product: product, radius: 18), + _KasirAvatar(product: product, size: 36, radius: 18), const Spacer(), Container( padding: const EdgeInsets.symmetric( @@ -1556,33 +1867,6 @@ class _ProductCard extends StatelessWidget { } } -/// Avatar produk di kasir: foto jika ada, fallback huruf pertama nama. -class _KasirAvatar extends StatelessWidget { - final Product product; - final double radius; - const _KasirAvatar({required this.product, this.radius = 18}); - - @override - Widget build(BuildContext context) { - final url = productImageUrl(product.imagePath); - return CircleAvatar( - radius: radius, - backgroundColor: kAccent.withValues(alpha: 0.15), - backgroundImage: url != null ? NetworkImage(url) : null, - child: url == null - ? Text( - product.name.isNotEmpty - ? product.name[0].toUpperCase() - : '?', - style: TextStyle( - color: kAccent, - fontWeight: FontWeight.w700, - fontSize: radius * 0.75), - ) - : null, - ); - } -} class _QtyButton extends StatelessWidget { final IconData icon;