chore: initial commit Dutra Store POS app
This commit is contained in:
120
lib/screens/home_page.dart
Normal file
120
lib/screens/home_page.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
1450
lib/screens/kasir_screen.dart
Normal file
1450
lib/screens/kasir_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
372
lib/screens/kelola_kasir_screen.dart
Normal file
372
lib/screens/kelola_kasir_screen.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
135
lib/screens/login_screen.dart
Normal file
135
lib/screens/login_screen.dart
Normal 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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
262
lib/screens/pengaturan_screen.dart
Normal file
262
lib/screens/pengaturan_screen.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
279
lib/screens/printer_screen.dart
Normal file
279
lib/screens/printer_screen.dart
Normal 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'),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
802
lib/screens/produk_screen.dart
Normal file
802
lib/screens/produk_screen.dart
Normal 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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
1026
lib/screens/riwayat_screen.dart
Normal file
1026
lib/screens/riwayat_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
54
lib/screens/scan_screen.dart
Normal file
54
lib/screens/scan_screen.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user