chore: initial commit Dutra Store POS app

This commit is contained in:
Onie
2026-07-23 22:12:32 +07:00
commit 22356f630e
175 changed files with 13934 additions and 0 deletions

120
lib/screens/home_page.dart Normal file
View File

@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'kasir_screen.dart';
import 'produk_screen.dart';
import 'riwayat_screen.dart';
import 'pengaturan_screen.dart';
import 'printer_screen.dart';
import '../state/app_state.dart';
import '../utils/colors.dart';
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
int _index = 0;
List<Widget> _screensForRole(String? role) {
final screens = <Widget>[const KasirScreen()];
if (role == 'admin') {
screens.addAll([
const ProdukScreen(),
const RiwayatScreen(),
const PengaturanScreen(),
]);
} else {
screens.addAll([
const RiwayatScreen(),
const PrinterScreen(),
]);
}
return screens;
}
List<BottomNavigationBarItem> _navItemsForRole(String? role) {
final items = <BottomNavigationBarItem>[
const BottomNavigationBarItem(
icon: Icon(Icons.point_of_sale),
label: 'Kasir',
),
];
if (role == 'admin') {
items.addAll([
const BottomNavigationBarItem(
icon: Icon(Icons.inventory_2),
label: 'Produk',
),
const BottomNavigationBarItem(
icon: Icon(Icons.receipt_long),
label: 'Riwayat',
),
const BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined),
label: 'Pengaturan',
),
]);
} else {
items.addAll([
const BottomNavigationBarItem(
icon: Icon(Icons.receipt_long),
label: 'Riwayat',
),
const BottomNavigationBarItem(
icon: Icon(Icons.print_outlined),
label: 'Printer',
),
]);
}
return items;
}
@override
Widget build(BuildContext context) {
final state = context.watch<AppState>();
final screens = _screensForRole(state.userRole);
final navItems = _navItemsForRole(state.userRole);
final safeIndex = _index >= screens.length ? 0 : _index;
return Scaffold(
appBar: AppBar(
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.storefront_rounded, color: kAccent),
const SizedBox(width: 8),
const Text('Dutra Store'),
],
),
centerTitle: true,
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.logout, color: kMuted),
onPressed: () async {
await context.read<AppState>().logout();
},
tooltip: 'Logout',
),
],
),
body: IndexedStack(
index: safeIndex,
children: screens,
),
bottomNavigationBar: navItems.length >= 2
? BottomNavigationBar(
currentIndex: safeIndex,
onTap: (i) => setState(() => _index = i),
items: navItems,
)
: null,
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,372 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../state/app_state.dart';
import '../utils/colors.dart';
class KelolaKasirScreen extends StatefulWidget {
final bool embedded;
const KelolaKasirScreen({super.key, this.embedded = false});
@override
State<KelolaKasirScreen> createState() => _KelolaKasirScreenState();
}
class _KelolaKasirScreenState extends State<KelolaKasirScreen> {
final TextEditingController _emailCtrl = TextEditingController();
final TextEditingController _passwordCtrl = TextEditingController();
final TextEditingController _nameCtrl = TextEditingController();
bool _loading = false;
String? _error;
@override
void initState() {
super.initState();
context.read<AppState>().loadKasirAccounts();
}
@override
void dispose() {
_emailCtrl.dispose();
_passwordCtrl.dispose();
_nameCtrl.dispose();
super.dispose();
}
Future<void> _addKasir() async {
final email = _emailCtrl.text.trim();
final password = _passwordCtrl.text;
final name = _nameCtrl.text.trim();
if (email.isEmpty || password.isEmpty) {
setState(() => _error = 'Email dan password harus diisi');
return;
}
setState(() {
_loading = true;
_error = null;
});
final state = context.read<AppState>();
final err = await state.createKasir(email, password, name);
if (mounted) {
setState(() => _loading = false);
if (err != null) {
setState(() => _error = err);
} else {
_emailCtrl.clear();
_passwordCtrl.clear();
_nameCtrl.clear();
}
}
}
@override
Widget build(BuildContext context) {
final body = _buildBody(context);
if (widget.embedded) return body;
return Scaffold(
appBar: AppBar(
title: const Text('Akun Kasir'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: body,
),
);
}
Widget _buildBody(BuildContext context) {
final state = context.watch<AppState>();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.person_add_outlined, color: kAccent),
const SizedBox(width: 8),
Text(
'Tambah Akun Kasir',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w700, color: kText),
),
],
),
const SizedBox(height: 14),
TextField(
controller: _nameCtrl,
decoration: const InputDecoration(
labelText: 'Nama Lengkap',
prefixIcon: Icon(Icons.person_outline),
),
),
const SizedBox(height: 10),
TextField(
controller: _emailCtrl,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
),
),
const SizedBox(height: 10),
TextField(
controller: _passwordCtrl,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Password',
prefixIcon: Icon(Icons.lock_outlined),
),
),
if (_error != null) ...[
const SizedBox(height: 10),
Text(_error!,
style: const TextStyle(color: kDanger, fontSize: 13)),
],
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _loading ? null : _addKasir,
icon: _loading
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: Color(0xFF1A1206)),
)
: const Icon(Icons.add),
label: Text(_loading ? 'Menyimpan...' : 'Tambah Kasir'),
),
),
],
),
),
),
const SizedBox(height: 12),
Text(
'Daftar Akun Kasir',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: kText),
),
const SizedBox(height: 10),
if (state.kasirAccounts.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: Column(
children: const [
Icon(Icons.people_outline, size: 48, color: kMuted),
SizedBox(height: 8),
Text('Belum ada akun kasir', style: TextStyle(color: kMuted)),
],
),
)
)
else
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: state.kasirAccounts.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, i) {
final kasir = state.kasirAccounts[i];
return Card(
child: ListTile(
leading: CircleAvatar(
backgroundColor: kAccent.withValues(alpha: 0.15),
child: Text(
(kasir['full_name']?.toString().isNotEmpty == true
? kasir['full_name'].toString()
: kasir['email']?.toString())
?.isNotEmpty ==
true
? (kasir['full_name']?.toString().isNotEmpty == true
? kasir['full_name'].toString()
: kasir['email'].toString())[0].toUpperCase()
: '?',
style: TextStyle(color: kAccent, fontWeight: FontWeight.w700),
),
),
title: Text(
(kasir['full_name']?.toString().isNotEmpty == true
? kasir['full_name'].toString()
: kasir['email']?.toString().isNotEmpty == true
? kasir['email'].toString()
: 'Tanpa Nama'),
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(
'${kasir['role'] == 'kasir' ? 'Kasir' : 'Admin'}${kasir['email']?.toString().isNotEmpty == true ? ' • ${kasir['email']}' : ''}',
style: const TextStyle(fontSize: 12, color: kMuted),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit_outlined, size: 20),
color: kAccent,
onPressed: () {
_showEditDialog(context, state, kasir);
},
),
IconButton(
icon: const Icon(Icons.delete_outline, size: 20),
color: kDanger,
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
icon: Icon(Icons.person_remove_outlined, color: kDanger, size: 28),
title: const Text('Hapus Akun'),
content: Text(
'Yakin ingin menghapus akun ${kasir['full_name']?.toString().isNotEmpty == true ? kasir['full_name'] : (kasir['email']?.toString().isNotEmpty == true ? kasir['email'] : '')}?'),
actions: [
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: () => Navigator.pop(context, 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(context, true),
style: FilledButton.styleFrom(
backgroundColor: kDanger,
),
child: const Text('Hapus'),
),
),
],
),
);
if (confirm == true && mounted) {
await state.deleteKasir(kasir['id'].toString());
}
},
),
],
),
),
);
},
),
],
);
}
void _showEditDialog(BuildContext context, AppState state, Map<String, dynamic> kasir) {
final emailCtrl = TextEditingController(text: kasir['email']?.toString() ?? '');
final nameCtrl = TextEditingController(text: kasir['full_name']?.toString() ?? '');
final passwordCtrl = TextEditingController();
bool isLoading = false;
String? error;
showDialog(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (ctx, setDialog) {
Future<void> submit() async {
final email = emailCtrl.text.trim();
final name = nameCtrl.text.trim();
final password = passwordCtrl.text;
if (email.isEmpty) {
setDialog(() => error = 'Email harus diisi');
return;
}
setDialog(() {
isLoading = true;
error = null;
});
final err = await state.updateKasir(
kasir['id'].toString(),
email,
name,
password.isEmpty ? null : password,
);
if (!dialogContext.mounted) return;
setDialog(() => isLoading = false);
if (err != null) {
setDialog(() => error = err);
} else {
Navigator.pop(dialogContext);
}
}
return AlertDialog(
title: const Text('Edit Akun Kasir'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: emailCtrl,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
),
),
const SizedBox(height: 10),
TextField(
controller: nameCtrl,
decoration: const InputDecoration(
labelText: 'Nama Lengkap',
prefixIcon: Icon(Icons.person_outline),
),
),
const SizedBox(height: 10),
TextField(
controller: passwordCtrl,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Password (kosongkan jika tidak diubah)',
prefixIcon: Icon(Icons.lock_outlined),
),
),
if (error != null) ...[
const SizedBox(height: 10),
Text(error!, style: const TextStyle(color: kDanger, fontSize: 13)),
],
],
),
actions: [
TextButton(
onPressed: isLoading ? null : () => Navigator.pop(dialogContext),
child: const Text('Batal'),
),
FilledButton(
onPressed: isLoading ? null : submit,
style: FilledButton.styleFrom(backgroundColor: kAccent),
child: isLoading
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: Color(0xFF1A1206)),
)
: const Text('Simpan'),
),
],
);
},
);
},
);
}
}

View File

@@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../state/app_state.dart';
import '../utils/colors.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _emailCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
String? _error;
bool _loading = false;
@override
void dispose() {
_emailCtrl.dispose();
_passwordCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
final email = _emailCtrl.text.trim();
final password = _passwordCtrl.text;
if (email.isEmpty || password.isEmpty) {
setState(() => _error = 'Email dan password harus diisi');
return;
}
setState(() {
_loading = true;
_error = null;
});
final state = context.read<AppState>();
final err = await state.login(email, password);
if (mounted) {
setState(() => _loading = false);
if (err != null) {
setState(() => _error = err);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: SingleChildScrollView(
padding: EdgeInsets.only(
left: 24,
right: 24,
top: 24,
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Column(
children: [
const SizedBox(height: 80),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: kAccent.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: const Icon(Icons.storefront_rounded,
size: 48, color: kAccent),
),
const SizedBox(height: 24),
Text(
'Dutra Store',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w800,
color: kText,
),
),
const SizedBox(height: 8),
Text(
'Masuk untuk melanjutkan',
style: TextStyle(color: kMuted, fontSize: 14),
),
const SizedBox(height: 32),
TextField(
controller: _emailCtrl,
keyboardType: TextInputType.emailAddress,
style: const TextStyle(color: kText),
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined, color: kMuted),
),
),
const SizedBox(height: 16),
TextField(
controller: _passwordCtrl,
obscureText: true,
style: const TextStyle(color: kText),
decoration: const InputDecoration(
labelText: 'Password',
prefixIcon: Icon(Icons.lock_outlined, color: kMuted),
),
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(_error!,
style: const TextStyle(color: kDanger, fontSize: 13)),
],
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _loading ? null : _submit,
child: _loading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Color(0xFF1A1206)),
)
: const Text('Masuk'),
),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,262 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../state/app_state.dart';
import '../utils/colors.dart';
import 'kelola_kasir_screen.dart';
import 'printer_screen.dart';
class PengaturanScreen extends StatefulWidget {
const PengaturanScreen({super.key});
@override
State<PengaturanScreen> createState() => _PengaturanScreenState();
}
class _PengaturanScreenState extends State<PengaturanScreen> {
final TextEditingController _storeNameCtrl = TextEditingController();
final TextEditingController _currencyCtrl = TextEditingController(text: 'IDR');
final TextEditingController _whatsappCtrl = TextEditingController();
final TextEditingController _notesCtrl = TextEditingController();
bool _resellerEnabled = true;
bool _loading = true;
@override
void initState() {
super.initState();
_loadSettings();
}
Future<void> _loadSettings() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_storeNameCtrl.text = prefs.getString('store_name') ?? 'Toko Grosir';
_currencyCtrl.text = prefs.getString('currency') ?? 'IDR';
_whatsappCtrl.text = prefs.getString('store_whatsapp') ?? '';
_notesCtrl.text = prefs.getString('store_notes') ?? '';
_resellerEnabled = prefs.getBool('reseller_enabled') ?? true;
_loading = false;
});
}
Future<void> _saveSettings() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('store_name', _storeNameCtrl.text.trim());
await prefs.setString('currency', _currencyCtrl.text.trim());
await prefs.setString('store_whatsapp', _whatsappCtrl.text.trim());
await prefs.setString('store_notes', _notesCtrl.text.trim());
await prefs.setBool('reseller_enabled', _resellerEnabled);
if (!mounted) return;
final state = context.read<AppState>();
state.setResellerEnabled(_resellerEnabled);
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Center(child: CircularProgressIndicator(color: kAccent));
}
return RefreshIndicator(
onRefresh: () async {
context.read<AppState>().recordManualRefresh();
await context.read<AppState>().refresh();
},
color: kAccent,
child: ListView(
padding: const EdgeInsets.all(12),
children: [
_SettingsListTile(
icon: Icons.store_outlined,
title: 'Pengaturan Toko',
subtitle: _whatsappCtrl.text.isEmpty
? '${_storeNameCtrl.text} • ${_currencyCtrl.text}'
: '${_storeNameCtrl.text} • ${_currencyCtrl.text} • ${_whatsappCtrl.text}',
onTap: () => _showStoreSettingsDialog(context),
),
const SizedBox(height: 8),
_SettingsListTile(
icon: Icons.people_outline,
title: 'Akun Kasir',
subtitle: 'Tambah, edit, atau hapus akun kasir',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const KelolaKasirScreen(),
),
);
},
),
const SizedBox(height: 8),
_SettingsListTile(
icon: Icons.print_outlined,
title: 'Printer Bluetooth',
subtitle: 'Scan, connect, dan cetak struk',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const PrinterScreen(),
),
);
},
),
],
),
);
}
Future<void> _showStoreSettingsDialog(BuildContext context) async {
final storeNameCtrl = TextEditingController(text: _storeNameCtrl.text);
final currencyCtrl = TextEditingController(text: _currencyCtrl.text);
final whatsappCtrl = TextEditingController(text: _whatsappCtrl.text);
final notesCtrl = TextEditingController(text: _notesCtrl.text);
bool resellerEnabled = _resellerEnabled;
final messenger = ScaffoldMessenger.of(context);
final result = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setModalState) => AlertDialog(
title: const Text('Pengaturan Toko'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: storeNameCtrl,
decoration: const InputDecoration(
labelText: 'Nama Toko',
prefixIcon: Icon(Icons.badge_outlined),
),
),
const SizedBox(height: 10),
TextField(
controller: currencyCtrl,
decoration: const InputDecoration(
labelText: 'Mata Uang',
prefixIcon: Icon(Icons.money_outlined),
),
),
const SizedBox(height: 10),
TextField(
controller: whatsappCtrl,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: 'No. WhatsApp',
prefixIcon: Icon(Icons.phone_outlined),
helperText: 'Contoh: 081234567890',
),
),
const SizedBox(height: 10),
TextField(
controller: notesCtrl,
maxLines: 3,
decoration: const InputDecoration(
labelText: 'Catatan Tambahan',
prefixIcon: Icon(Icons.notes_outlined),
helperText: 'Opsional, akan dicetak di struk',
),
),
const SizedBox(height: 10),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Mode Reseller Aktif',
style: TextStyle(fontSize: 14)),
subtitle: const Text(
'Jika aktif, kasir bisa toggle harga reseller per transaksi',
style: TextStyle(fontSize: 12, color: kMuted)),
value: resellerEnabled,
onChanged: (v) => setModalState(() => resellerEnabled = v),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Batal'),
),
FilledButton.icon(
onPressed: () async {
_storeNameCtrl.text = storeNameCtrl.text.trim();
_currencyCtrl.text = currencyCtrl.text.trim();
_whatsappCtrl.text = whatsappCtrl.text.trim();
_notesCtrl.text = notesCtrl.text.trim();
_resellerEnabled = resellerEnabled;
await _saveSettings();
if (ctx.mounted) {
Navigator.pop(ctx, true);
}
},
icon: const Icon(Icons.save_outlined, size: 18),
label: const Text('Simpan'),
),
],
),
),
);
if (result == true && mounted) {
messenger.showSnackBar(
const SnackBar(
content: Text('Pengaturan tersimpan'),
backgroundColor: kSuccess,
),
);
}
}
@override
void dispose() {
_storeNameCtrl.dispose();
_currencyCtrl.dispose();
_whatsappCtrl.dispose();
_notesCtrl.dispose();
super.dispose();
}
}
class _SettingsListTile extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
const _SettingsListTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: kAccent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: kAccent, size: 20),
),
title: Text(
title,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700),
),
subtitle: Text(
subtitle,
style: const TextStyle(fontSize: 12, color: kMuted),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Icon(Icons.chevron_right_rounded, color: kMuted, size: 20),
onTap: onTap,
),
);
}
}

View File

@@ -0,0 +1,279 @@
import 'package:flutter/material.dart';
import 'package:print_bluetooth_thermal/print_bluetooth_thermal.dart';
import '../services/printer_service.dart';
import '../utils/colors.dart';
class PrinterScreen extends StatefulWidget {
final bool embedded;
const PrinterScreen({super.key, this.embedded = false});
@override
State<PrinterScreen> createState() => _PrinterScreenState();
}
class _PrinterScreenState extends State<PrinterScreen> {
List<BluetoothInfo> _devices = [];
bool _loading = false;
bool _scanning = false;
bool _connecting = false;
@override
void initState() {
super.initState();
_loadDevices();
}
Future<void> _loadDevices() async {
setState(() => _loading = true);
try {
final enabled = await printerService.isBluetoothEnabled();
if (!enabled) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Bluetooth belum aktif. Nyalakan Bluetooth di HP.'),
backgroundColor: kDanger,
),
);
}
}
_devices = await printerService.getPairedDevices();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Gagal memuat printer: $e')),
);
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _scan() async {
setState(() => _scanning = true);
try {
_devices = await printerService.getPairedDevices();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Gagal scan: $e')),
);
}
} finally {
if (mounted) setState(() => _scanning = false);
}
}
Future<void> _connect(BluetoothInfo device) async {
setState(() => _connecting = true);
final ok = await printerService.connect(device.macAdress);
if (mounted) {
setState(() => _connecting = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(ok
? 'Terhubung ke ${device.name}'
: 'Gagal hubungkan ${device.name}'),
backgroundColor: ok ? kSuccess : kDanger,
),
);
if (ok) setState(() {});
}
}
Future<void> _disconnect() async {
await printerService.disconnect();
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
final body = _buildBody(context);
if (widget.embedded) return body;
return Scaffold(
appBar: AppBar(
title: const Text('Printer Bluetooth'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: body,
),
);
}
Widget _buildBody(BuildContext context) {
final connected = printerService.isConnected;
final connectedMac = printerService.connectedMac;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.print_outlined, color: kAccent),
const SizedBox(width: 8),
Text(
'Printer Bluetooth',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w700, color: kText),
),
],
),
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: connected
? kSuccess.withValues(alpha: 0.12)
: kSurface,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: connected ? kSuccess : kBorder,
),
),
child: Row(
children: [
Icon(
connected ? Icons.bluetooth_connected : Icons.bluetooth,
color: connected ? kSuccess : kMuted,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
connected ? 'Terhubung' : 'Tidak terhubung',
style: TextStyle(
fontWeight: FontWeight.w600,
color: connected ? kSuccess : kMuted,
),
),
if (connected && connectedMac != null)
Text(
connectedMac,
style: const TextStyle(
fontSize: 12, color: kMuted),
),
],
),
),
if (connected)
TextButton(
onPressed: _connecting ? null : _disconnect,
child: const Text('Putus'),
),
],
),
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _scanning ? null : _scan,
icon: _scanning
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: kAccent),
)
: const Icon(Icons.refresh),
label: Text(_scanning ? 'Scanning...' : 'Scan Ulang'),
),
),
],
),
],
),
),
),
const SizedBox(height: 12),
Text(
'Perangkat Tersedia',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: kText),
),
const SizedBox(height: 10),
if (_loading)
const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: CircularProgressIndicator(color: kAccent),
))
else if (_devices.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: Column(
children: [
Icon(Icons.print_disabled_outlined, size: 48, color: kMuted),
SizedBox(height: 8),
Text('Tidak ada printer terpasang',
style: TextStyle(color: kMuted)),
SizedBox(height: 4),
Text('Pasangkan printer di pengaturan Bluetooth HP',
style: TextStyle(color: kMuted, fontSize: 12)),
],
),
),
)
else
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _devices.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, i) {
final d = _devices[i];
final isThis = connected && connectedMac == d.macAdress;
return Card(
child: ListTile(
leading: CircleAvatar(
backgroundColor: isThis
? kSuccess.withValues(alpha: 0.15)
: kAccent.withValues(alpha: 0.15),
child: Icon(
isThis ? Icons.bluetooth_connected : Icons.print_outlined,
color: isThis ? kSuccess : kAccent,
),
),
title: Text(d.name.isNotEmpty ? d.name : 'Printer',
style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text(d.macAdress,
style: const TextStyle(fontSize: 12, color: kMuted)),
trailing: _connecting
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: kAccent),
)
: isThis
? const Chip(
label: Text('Aktif',
style: TextStyle(fontSize: 11)),
backgroundColor: kSuccess,
labelPadding: EdgeInsets.zero,
)
: TextButton(
onPressed: () => _connect(d),
child: const Text('Hubungkan'),
),
),
);
},
),
],
);
}
}

View File

@@ -0,0 +1,802 @@
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)),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
class ScanScreen extends StatefulWidget {
final String title;
const ScanScreen({
super.key,
required this.title,
});
@override
State<ScanScreen> createState() => _ScanScreenState();
}
class _ScanScreenState extends State<ScanScreen> {
final MobileScannerController _controller = MobileScannerController();
bool _isScanned = false;
void _handleBarcode(BarcodeCapture capture) {
if (_isScanned) return;
final barcode = capture.barcodes.firstOrNull;
if (barcode != null && barcode.rawValue != null) {
final code = barcode.rawValue!.trim();
if (code.isNotEmpty) {
_isScanned = true;
_controller.stop();
Navigator.pop(context, code);
}
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
centerTitle: true,
),
body: MobileScanner(
controller: _controller,
onDetect: _handleBarcode,
),
);
}
}