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

176
lib/main.dart Normal file
View File

@@ -0,0 +1,176 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'config.dart';
import 'state/app_state.dart';
import 'services/printer_service.dart';
import 'screens/login_screen.dart';
import 'screens/home_page.dart';
import 'utils/colors.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Supabase.initialize(url: supabaseUrl, publishableKey: supabaseAnonKey);
await printerService.init();
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
late final AppState _appState;
@override
void initState() {
super.initState();
_appState = AppState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_appState.stopPolling();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_appState.startPolling();
} else {
_appState.stopPolling();
}
}
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: _appState,
child: MaterialApp(
title: 'Dutra Store',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: kBackground,
colorScheme: ColorScheme.dark(
primary: kAccent,
secondary: kSuccess,
surface: kSurface,
onSurface: kText,
error: kDanger,
),
appBarTheme: const AppBarTheme(
backgroundColor: kBackground,
foregroundColor: kText,
elevation: 0,
centerTitle: true,
titleTextStyle: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: kText,
),
),
cardTheme: CardThemeData(
color: kSurface2,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(kRadius),
side: const BorderSide(color: kBorder),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: kSurface2,
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: kBorder),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: kAccent, width: 1.5),
),
labelStyle: const TextStyle(color: kMuted),
hintStyle: const TextStyle(color: kMuted),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: kAccent,
foregroundColor: const Color(0xFF1A1206),
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
),
dialogTheme: DialogThemeData(
backgroundColor: kSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
titleTextStyle: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: kText,
),
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: kSurface,
selectedItemColor: kAccent,
unselectedItemColor: kMuted,
selectedLabelStyle: TextStyle(fontWeight: FontWeight.w600),
type: BottomNavigationBarType.fixed,
elevation: 0,
),
),
home: const AuthGate(),
),
);
}
}
class AuthGate extends StatelessWidget {
const AuthGate({super.key});
@override
Widget build(BuildContext context) {
return Selector<AppState, bool>(
selector: (context, state) => state.loading,
builder: (context, loading, child) {
if (loading) {
return const Scaffold(
body: Center(
child: CircularProgressIndicator(color: kAccent),
),
);
}
return Selector<AppState, bool>(
selector: (context, state) => state.isLoggedIn,
builder: (context, loggedIn, child) {
if (loggedIn) {
return const HomePage();
}
return const LoginScreen();
},
);
},
);
}
}

51
lib/models/product.dart Normal file
View File

@@ -0,0 +1,51 @@
class Product {
final String id;
final String name;
final String barcode;
final double price;
final double priceReseller;
final int stock;
final int minStock;
final double hargaPokok;
Product({
required this.id,
required this.name,
this.barcode = '',
required this.price,
this.priceReseller = 0,
required this.stock,
this.minStock = 5,
this.hargaPokok = 0,
});
factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: json['id'] as String,
name: json['name'] as String,
barcode: json['barcode'] != null ? json['barcode'] as String : '',
price: (json['price'] as num).toDouble(),
priceReseller: json['price_reseller'] != null
? (json['price_reseller'] as num).toDouble()
: 0,
stock: json['stock'] as int,
minStock: json['min_stock'] != null
? (json['min_stock'] as int)
: 5,
hargaPokok: json['harga_pokok'] != null
? (json['harga_pokok'] as num).toDouble()
: 0,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'barcode': barcode,
'price': price,
'price_reseller': priceReseller,
'stock': stock,
'min_stock': minStock,
'harga_pokok': hargaPokok,
};
}

118
lib/models/sale.dart Normal file
View File

@@ -0,0 +1,118 @@
class SaleItem {
final String productId;
final String name;
final double price;
final int qty;
final bool isReseller;
SaleItem({
required this.productId,
required this.name,
required this.price,
required this.qty,
this.isReseller = false,
});
double get subtotal => price * qty;
factory SaleItem.fromJson(Map<String, dynamic> json) {
return SaleItem(
productId: json['product_id'] as String? ?? '',
name: json['name'] as String,
price: (json['price'] as num).toDouble(),
qty: json['qty'] as int,
isReseller: json['isReseller'] as bool? ?? false,
);
}
Map<String, dynamic> toJson() => {
'product_id': productId,
'name': name,
'price': price,
'qty': qty,
'isReseller': isReseller,
};
}
class Sale {
final String id;
final DateTime date;
final String? userId;
final List<SaleItem> items;
final double subtotal;
final double discount;
final double total;
final double paid;
final double change;
final double totalCost;
final bool isCancelled;
final String paymentMethod;
final String paymentStatus;
final double dueAmount;
final String? customerPhone;
final Map<String, double>? splitPayment;
Sale({
required this.id,
required this.date,
this.userId,
required this.items,
required this.subtotal,
required this.discount,
required this.total,
required this.paid,
required this.change,
required this.totalCost,
this.isCancelled = false,
this.paymentMethod = 'tunai',
this.paymentStatus = 'paid',
this.dueAmount = 0.0,
this.customerPhone,
this.splitPayment,
});
factory Sale.fromJson(Map<String, dynamic> json) {
return Sale(
id: json['id'] as String,
date: DateTime.parse(json['created_at'] as String),
userId: json['user_id'] as String?,
items: (json['items'] as List)
.map((e) => SaleItem.fromJson(e as Map<String, dynamic>))
.toList(),
subtotal: (json['subtotal'] as num).toDouble(),
discount: (json['discount'] as num).toDouble(),
total: (json['total'] as num).toDouble(),
paid: (json['paid'] as num).toDouble(),
change: (json['change'] as num).toDouble(),
totalCost: (json['total_cost'] as num?)?.toDouble() ?? 0,
isCancelled: json['is_cancelled'] as bool? ?? false,
paymentMethod: json['payment_method'] as String? ?? 'tunai',
paymentStatus: json['payment_status'] as String? ?? 'paid',
dueAmount: (json['due_amount'] as num?)?.toDouble() ?? 0.0,
customerPhone: json['customer_phone'] as String?,
splitPayment: json['split_payment'] != null
? Map<String, double>.from(json['split_payment'] as Map)
: null,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'created_at': date.toIso8601String(),
'user_id': userId,
'items': items.map((e) => e.toJson()).toList(),
'subtotal': subtotal,
'discount': discount,
'total': total,
'paid': paid,
'change': change,
'total_cost': totalCost,
'is_cancelled': isCancelled,
'payment_method': paymentMethod,
'payment_status': paymentStatus,
'due_amount': dueAmount,
'customer_phone': customerPhone,
if (splitPayment != null && splitPayment!.isNotEmpty)
'split_payment': splitPayment,
};
}

View File

@@ -0,0 +1,25 @@
class UserProfile {
final String id;
final String role;
final String? fullName;
UserProfile({
required this.id,
required this.role,
this.fullName,
});
factory UserProfile.fromJson(Map<String, dynamic> json) {
return UserProfile(
id: json['id'] as String,
role: json['role'] as String,
fullName: json['full_name'] as String?,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'role': role,
'full_name': fullName,
};
}

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,
),
);
}
}

View File

@@ -0,0 +1,167 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
class ExportService {
static final ExportService _instance = ExportService._internal();
factory ExportService() => _instance;
ExportService._internal();
Future<String?> exportToPdf(List<Map<String, dynamic>> transactions,
String storeName, DateTimeRange? dateRange) async {
try {
debugPrint('exportToPdf called with ${transactions.length} transactions');
final pdf = pw.Document();
pdf.addPage(
pw.Page(
build: (context) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(storeName, style: pw.TextStyle(fontSize: 24, fontWeight: pw.FontWeight.bold)),
pw.SizedBox(height: 8),
if (dateRange != null)
pw.Text('${_formatDate(dateRange.start)} - ${_formatDate(dateRange.end)}',
style: pw.TextStyle(fontSize: 12)),
pw.SizedBox(height: 16),
pw.Table(
border: pw.TableBorder.all(),
children: [
pw.TableRow(
children: [
pw.Padding(padding: pw.EdgeInsets.all(8), child: pw.Text('Tanggal', style: pw.TextStyle(fontWeight: pw.FontWeight.bold))),
pw.Padding(padding: pw.EdgeInsets.all(8), child: pw.Text('ID', style: pw.TextStyle(fontWeight: pw.FontWeight.bold))),
pw.Padding(padding: pw.EdgeInsets.all(8), child: pw.Text('Total', style: pw.TextStyle(fontWeight: pw.FontWeight.bold))),
pw.Padding(padding: pw.EdgeInsets.all(8), child: pw.Text('Bayar', style: pw.TextStyle(fontWeight: pw.FontWeight.bold))),
pw.Padding(padding: pw.EdgeInsets.all(8), child: pw.Text('Kembali', style: pw.TextStyle(fontWeight: pw.FontWeight.bold))),
],
),
for (final tx in transactions)
pw.TableRow(
children: [
pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text(_formatDate(tx['date']))),
pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text(tx['id'])),
pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text(tx['total'])),
pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text(tx['paid'])),
pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text(tx['change'])),
],
),
],
),
],
);
},
),
);
Directory? directory;
try {
directory = await getTemporaryDirectory();
debugPrint('PDF using temporary directory: ${directory.path}');
} catch (e) {
debugPrint('getTemporaryDirectory failed for PDF: $e');
return null;
}
final file = File('${directory.path}/laporan_transaksi_${DateTime.now().millisecondsSinceEpoch}.pdf');
try {
await file.writeAsBytes(await pdf.save());
debugPrint('PDF file written: ${file.path}, exists: ${await file.exists()}');
} catch (e) {
debugPrint('Failed to write PDF file: $e');
return null;
}
return file.path;
} catch (e) {
debugPrint('exportToPdf error: $e');
return null;
}
}
Future<String?> exportToCsv(List<Map<String, dynamic>> transactions) async {
try {
debugPrint('=== CSV Export Started ===');
debugPrint('Transactions count: ${transactions.length}');
if (transactions.isEmpty) {
debugPrint('No transactions to export');
return null;
}
final buffer = StringBuffer();
buffer.writeln('Tanggal,ID Transaksi,Total,Bayar,Kembali,Item,Diskon');
for (final tx in transactions) {
final date = _formatDate(tx['date']);
final id = tx['id'];
final total = tx['total'];
final paid = tx['paid'];
final change = tx['change'];
final items = tx['items'] is List ? (tx['items'] as List).length : (tx['items'] as int? ?? 0);
final discount = tx['discount'] ?? 0;
buffer.writeln('$date,$id,$total,$paid,$change,$items,$discount');
}
debugPrint('CSV buffer created, length: ${buffer.toString().length}');
String? filePath;
try {
final tempDir = await getTemporaryDirectory();
debugPrint('Temp dir: ${tempDir.path}');
final timestamp = DateTime.now().millisecondsSinceEpoch;
final fileName = 'laporan_transaksi_$timestamp.csv';
filePath = '${tempDir.path}/$fileName';
final file = File(filePath);
await file.writeAsString(buffer.toString());
debugPrint('CSV file written: $filePath');
debugPrint('File exists: ${await file.exists()}');
debugPrint('File size: ${await file.length()}');
} catch (e) {
debugPrint('Error creating CSV file: $e');
return null;
}
debugPrint('=== CSV Export Completed Successfully ===');
return filePath;
} catch (e) {
debugPrint('=== CSV Export Failed ===');
debugPrint('Error: $e');
debugPrint('Stack trace: ${StackTrace.current}');
return null;
}
}
Future<void> shareFile(String filePath) async {
try {
debugPrint('Sharing file: $filePath');
final file = File(filePath);
final exists = await file.exists();
debugPrint('File exists: $exists, size: ${exists ? await file.length() : 0}');
if (!exists) {
debugPrint('File does not exist, cannot share');
return;
}
await Share.shareXFiles([XFile(filePath)], text: 'Laporan Transaksi');
debugPrint('Share completed');
} catch (e) {
debugPrint('shareFile error: $e');
rethrow;
}
}
String _formatDate(dynamic date) {
if (date is DateTime) {
return '${date.day}/${date.month}/${date.year}';
}
return date.toString();
}
}

View File

@@ -0,0 +1,156 @@
import 'dart:async';
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
class OfflineQueueService {
static final OfflineQueueService _instance = OfflineQueueService._internal();
factory OfflineQueueService() => _instance;
OfflineQueueService._internal();
final SupabaseClient _supabase = Supabase.instance.client;
final Connectivity _connectivity = Connectivity();
List<Map<String, dynamic>> _queue = [];
bool _isOnline = true;
bool _syncing = false;
static const Duration _timeout = Duration(seconds: 30);
Future<T> _withTimeout<T>(Future<T> future, [String? operation]) async {
try {
return await future.timeout(
_timeout,
onTimeout: () => throw TimeoutException('${operation ?? 'Operasi'} timeout setelah $_timeout'),
);
} on TimeoutException catch (e) {
print('OfflineQueue timeout: $e');
rethrow;
}
}
List<Map<String, dynamic>> get queue => List.unmodifiable(_queue);
bool get isOnline => _isOnline;
bool get hasPendingItems => _queue.isNotEmpty;
Future<void> init() async {
await _loadQueue();
_connectivity.onConnectivityChanged.listen(_updateConnectionStatus);
await _checkConnection();
if (_isOnline) await _processQueue();
}
Future<void> _loadQueue() async {
final prefs = await SharedPreferences.getInstance();
final queueJson = prefs.getString('offline_queue');
if (queueJson != null) {
final List<dynamic> decoded = json.decode(queueJson);
_queue = decoded.cast<Map<String, dynamic>>();
}
}
Future<void> _saveQueue() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('offline_queue', json.encode(_queue));
}
Future<void> _checkConnection() async {
final result = await _connectivity.checkConnectivity();
_updateConnectionStatus(result);
}
void _updateConnectionStatus(List<ConnectivityResult> result) {
_isOnline = result.any((r) => r != ConnectivityResult.none);
if (_isOnline && _queue.isNotEmpty) {
_processQueue();
}
}
Future<void> addToQueue(Map<String, dynamic> transaction) async {
_queue.add(transaction);
await _saveQueue();
if (_isOnline) {
await _processQueue();
}
}
Future<void> _processQueue() async {
if (_syncing || !_isOnline || _queue.isEmpty) return;
_syncing = true;
while (_queue.isNotEmpty && _isOnline) {
final item = _queue.first;
try {
await _processTransaction(item);
_queue.removeAt(0);
await _saveQueue();
} catch (e) {
_syncing = false;
return;
}
}
_syncing = false;
}
Future<void> _processTransaction(Map<String, dynamic> transaction) async {
final sale = transaction['sale'] as Map<String, dynamic>;
final items = (transaction['items'] as List?) ?? [];
final saleId = sale['id'] as String;
final existing = await _withTimeout(
_supabase
.from('sales')
.select('id, total_cost')
.eq('id', saleId)
.maybeSingle(),
'Check existing sale',
);
if (existing == null) {
await _withTimeout(
_supabase.from('sales').insert(sale),
'Insert queued sale',
);
} else {
final existingTotalCost = (existing['total_cost'] as num?)?.toDouble() ?? 0;
if (existingTotalCost <= 0 && sale['total_cost'] != null) {
await _withTimeout(
_supabase
.from('sales')
.update({'total_cost': sale['total_cost']})
.eq('id', saleId),
'Update sale total_cost',
);
}
}
for (final it in items) {
final productId = it['product_id'] as String;
final qty = (it['qty'] as num).toInt();
final product = await _withTimeout(
_supabase
.from('products')
.select()
.eq('id', productId)
.maybeSingle(),
'Load product for stock update',
);
if (product != null) {
final currentStock = (product['stock'] as num).toInt();
final newStock = (currentStock - qty).clamp(0, 1 << 62);
await _withTimeout(
_supabase
.from('products')
.update({'stock': newStock})
.eq('id', productId),
'Update product stock',
);
}
}
}
Future<void> clearQueue() async {
_queue.clear();
await _saveQueue();
}
}

View File

@@ -0,0 +1,223 @@
import 'package:esc_pos_utils_plus/esc_pos_utils_plus.dart';
import 'package:print_bluetooth_thermal/print_bluetooth_thermal.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/sale.dart';
import '../utils/format.dart';
class PrinterService {
static const String _printerKey = 'selected_printer_mac';
String? _connectedMac;
bool _isConnected = false;
String? get connectedMac => _connectedMac;
bool get isConnected => _isConnected;
Future<void> init() async {
final prefs = await SharedPreferences.getInstance();
_connectedMac = prefs.getString(_printerKey);
if (_connectedMac != null) {
await connect(_connectedMac!);
}
}
Future<bool> isBluetoothEnabled() async {
return PrintBluetoothThermal.bluetoothEnabled;
}
Future<List<BluetoothInfo>> getPairedDevices() async {
final list = await PrintBluetoothThermal.pairedBluetooths;
return list;
}
Future<bool> connect(String mac) async {
try {
final connected = await PrintBluetoothThermal.connect(macPrinterAddress: mac);
_isConnected = connected;
if (connected) {
_connectedMac = mac;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_printerKey, mac);
}
return connected;
} catch (_) {
_isConnected = false;
return false;
}
}
Future<void> disconnect() async {
try {
await PrintBluetoothThermal.disconnect;
} catch (_) {}
_isConnected = false;
}
Future<void> forgetPrinter() async {
_connectedMac = null;
_isConnected = false;
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_printerKey);
}
Future<List<int>> _buildReceiptBytes(Sale sale, String storeName, {String? kasirName, String? whatsapp, String? notes}) async {
final profile = await CapabilityProfile.load();
final generator = Generator(PaperSize.mm58, profile);
final bytes = <int>[];
bytes.addAll(generator.text(
storeName,
styles: const PosStyles(
align: PosAlign.center,
bold: true,
height: PosTextSize.size2,
),
));
bytes.addAll(generator.text(
formatTanggal(sale.date),
styles: const PosStyles(align: PosAlign.center),
));
bytes.addAll(generator.text(
formatWaktu(sale.date),
styles: const PosStyles(align: PosAlign.center),
));
bytes.addAll(generator.text(
'#${sale.id.toUpperCase()}',
styles: const PosStyles(align: PosAlign.center),
));
bytes.addAll(generator.hr());
for (final it in sale.items) {
bytes.addAll(generator.text('${it.name} x${it.qty}',
styles: const PosStyles(bold: true)));
bytes.addAll(generator.row([
PosColumn(
text: formatRupiah(it.price),
width: 6,
),
PosColumn(
text: formatRupiah(it.subtotal),
width: 6,
styles: const PosStyles(align: PosAlign.right),
),
]));
}
bytes.addAll(generator.hr());
if (sale.discount > 0) {
bytes.addAll(generator.row([
PosColumn(text: 'Diskon', width: 6),
PosColumn(
text: '-${formatRupiah(sale.discount)}',
width: 6,
styles: const PosStyles(align: PosAlign.right),
),
]));
}
bytes.addAll(generator.row([
PosColumn(
text: 'TOTAL',
width: 6,
styles: const PosStyles(bold: true),
),
PosColumn(
text: formatRupiah(sale.total),
width: 6,
styles: const PosStyles(bold: true, align: PosAlign.right),
),
]));
final methodLabel = sale.paymentMethod == 'transfer'
? 'Bank'
: sale.paymentMethod == 'qris'
? 'QRIS'
: sale.paymentMethod == 'split'
? 'Split Payment'
: 'Tunai';
bytes.addAll(generator.row([
PosColumn(text: 'Metode', width: 6),
PosColumn(
text: methodLabel,
width: 6,
styles: const PosStyles(align: PosAlign.right),
),
]));
if (sale.paymentMethod == 'tunai') {
bytes.addAll(generator.row([
PosColumn(text: 'Bayar', width: 6),
PosColumn(
text: formatRupiah(sale.paid),
width: 6,
styles: const PosStyles(align: PosAlign.right),
),
]));
bytes.addAll(generator.row([
PosColumn(text: 'Kembali', width: 6),
PosColumn(
text: formatRupiah(sale.change),
width: 6,
styles: const PosStyles(align: PosAlign.right),
),
]));
}
if (sale.splitPayment != null && sale.splitPayment!.isNotEmpty) {
for (final entry in sale.splitPayment!.entries) {
final label = entry.key == 'tunai'
? 'Tunai'
: entry.key == 'transfer'
? 'Transfer'
: 'QRIS';
bytes.addAll(generator.row([
PosColumn(text: 'Split $label', width: 6),
PosColumn(
text: formatRupiah(entry.value),
width: 6,
styles: const PosStyles(align: PosAlign.right),
),
]));
}
}
bytes.addAll(generator.hr());
if (kasirName != null && kasirName.isNotEmpty) {
bytes.addAll(generator.text('Kasir: $kasirName',
styles: const PosStyles(align: PosAlign.center)));
}
if (whatsapp != null && whatsapp.isNotEmpty) {
bytes.addAll(generator.text('WA: $whatsapp',
styles: const PosStyles(align: PosAlign.center)));
}
if (notes != null && notes.trim().isNotEmpty) {
final noteLines = notes.trim().split('\n');
for (final line in noteLines) {
final text = line.trim();
if (text.isEmpty) continue;
bytes.addAll(generator.text(text,
styles: const PosStyles(align: PosAlign.center)));
}
}
bytes.addAll(generator.hr());
bytes.addAll(generator.text(
'Terima kasih!',
styles: const PosStyles(align: PosAlign.center),
));
bytes.addAll(generator.feed(2));
bytes.addAll(generator.cut());
return bytes;
}
Future<String?> printSale(Sale sale, String storeName, {String? kasirName, String? whatsapp, String? notes}) async {
if (!_isConnected || _connectedMac == null) {
return 'Printer belum terhubung';
}
try {
final bytes = await _buildReceiptBytes(sale, storeName, kasirName: kasirName, whatsapp: whatsapp, notes: notes);
final result = await PrintBluetoothThermal.writeBytes(bytes);
if (result) return null;
return 'Gagal mengirim ke printer';
} catch (e) {
return e.toString();
}
}
}
final printerService = PrinterService();

760
lib/state/app_state.dart Normal file
View File

@@ -0,0 +1,760 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/product.dart';
import '../models/sale.dart';
import '../models/user_profile.dart';
import '../services/offline_queue_service.dart';
import '../utils/format.dart';
import '../config.dart';
class CartLine {
final Product product;
int qty;
bool isReseller;
CartLine({
required this.product,
this.qty = 1,
this.isReseller = false,
});
double get price => isReseller ? product.priceReseller : product.price;
}
class AppState extends ChangeNotifier {
final SupabaseClient _supabase = Supabase.instance.client;
// Client dengan service_role key untuk operasi admin (buat/edit/hapus akun kasir).
// PERINGATAN: key ini bypass RLS — hanya untuk app POS di device terpercaya.
final SupabaseClient _admin = SupabaseClient(supabaseUrl, supabaseServiceKey);
static const Duration _timeout = Duration(seconds: 30);
Future<T> _withTimeout<T>(Future<T> future, [String? operation]) async {
try {
return await future.timeout(
_timeout,
onTimeout: () => throw TimeoutException('${operation ?? 'Operasi'} timeout setelah $_timeout'),
);
} on TimeoutException catch (e) {
debugPrint('Supabase timeout: $e');
rethrow;
}
}
List<Product> products = [];
List<Sale> sales = [];
List<CartLine> cart = [];
List<Map<String, dynamic>> kasirAccounts = [];
bool loading = true;
String? error;
static const int _pageSize = 30;
int _visibleProductCount = _pageSize;
int _visibleSalesCount = _pageSize;
List<Product> get displayedProducts =>
products.take(_visibleProductCount).toList();
List<Sale> get displayedSales =>
sales.take(_visibleSalesCount).toList();
int get visibleProductCount => _visibleProductCount;
int get visibleSalesCount => _visibleSalesCount;
int get pageSize => _pageSize;
bool get hasMoreProducts => _visibleProductCount < products.length;
bool get hasMoreSales => _visibleSalesCount < sales.length;
void resetPagination() {
_visibleProductCount = _pageSize;
_visibleSalesCount = _pageSize;
notifyListeners();
}
void loadMoreProducts() {
if (!hasMoreProducts) return;
_visibleProductCount = (_visibleProductCount + _pageSize).clamp(0, products.length);
notifyListeners();
}
void loadMoreSales() {
if (!hasMoreSales) return;
_visibleSalesCount = (_visibleSalesCount + _pageSize).clamp(0, sales.length);
notifyListeners();
}
String customerType = 'umum';
bool resellerEnabled = true;
String? userId;
String? userRole;
UserProfile? _profile;
final Map<String, String> _profileNames = {};
double _discount = 0;
bool get isAdmin => userRole == 'admin';
bool get isKasir => userRole == 'kasir';
bool get isLoggedIn => userId != null;
String getProfileName(String? uid) {
if (uid == null) return 'Tidak diketahui';
if (uid == userId) return _profile?.fullName ?? 'Admin';
return _profileNames[uid] ?? 'Kasir';
}
AppState() {
_init();
}
Timer? _pollTimer;
static const Duration _pollInterval = Duration(minutes: 1);
DateTime? _lastManualRefresh;
void recordManualRefresh() {
_lastManualRefresh = DateTime.now();
}
void startPolling() {
stopPolling();
_pollTimer = Timer.periodic(_pollInterval, (_) => _poll());
}
void stopPolling() {
_pollTimer?.cancel();
_pollTimer = null;
}
Future<void> _poll() async {
if (_lastManualRefresh != null) {
final elapsed = DateTime.now().difference(_lastManualRefresh!);
if (elapsed < const Duration(seconds: 15)) return;
}
await refresh();
}
Future<void> refresh() async {
await _loadProducts();
await _loadSales();
notifyListeners();
}
final OfflineQueueService _offlineQueue = OfflineQueueService();
double get subtotal =>
cart.fold(0, (sum, line) => sum + line.price * line.qty);
double get discount => _discount;
double get total => (subtotal - _discount).clamp(0, double.infinity);
bool get canCheckout => cart.isNotEmpty && total > 0;
/* ---------- Init & Realtime ---------- */
Future<void> _init() async {
try {
await _initAuth();
await _loadProducts();
await _loadSales();
await loadKasirAccounts();
await _loadProfileNames();
await _loadSettings();
_subscribe();
await _seedAdminIfEmpty();
await _offlineQueue.init();
startPolling();
} catch (e) {
error = e.toString();
}
loading = false;
notifyListeners();
}
Future<void> _loadSettings() async {
final prefs = await SharedPreferences.getInstance();
resellerEnabled = prefs.getBool('reseller_enabled') ?? true;
notifyListeners();
}
Future<void> setResellerEnabled(bool value) async {
resellerEnabled = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('reseller_enabled', value);
if (!value) {
setCustomerType('umum');
for (final line in cart) {
if (line.isReseller) toggleLineMode(line.product.id);
}
}
notifyListeners();
}
Future<void> _initAuth() async {
final session = _supabase.auth.currentSession;
if (session != null) {
userId = session.user.id;
await _loadProfile();
}
}
Future<void> _loadProfile() async {
if (userId == null) return;
try {
final data = await _supabase
.from('profiles')
.select()
.eq('id', userId!)
.maybeSingle();
if (data != null) {
_profile = UserProfile.fromJson(data);
userRole = _profile!.role;
}
} catch (_) {}
}
Future<void> _seedAdminIfEmpty() async {
try {
final count = await _withTimeout(
_admin.from('profiles').select().count(),
'Check admin profiles',
);
if (count.count == 0) {
await _withTimeout(
_admin.auth.admin.createUser(
AdminUserAttributes(email: 'admin@posgrosir.local', password: 'admin123'),
),
'Create default admin',
);
}
} catch (_) {}
}
Future<String?> login(String email, String password) async {
try {
final res = await _withTimeout(
_supabase.auth.signInWithPassword(
email: email,
password: password,
),
'Login',
);
userId = res.user?.id;
await _loadProfile();
notifyListeners();
return null;
} on AuthException catch (e) {
return e.message;
} catch (e) {
return e.toString();
}
}
// Reserved for future self-service signup flow.
// Currently unused per PRD §5.3: all devices must be pre-provisioned by admin.
Future<String?> signup(String email, String password) async {
try {
final res = await _withTimeout(
_supabase.auth.signUp(
email: email,
password: password,
),
'Signup',
);
userId = res.user?.id;
await _loadProfile();
notifyListeners();
return null;
} on AuthException catch (e) {
return e.message;
} catch (e) {
return e.toString();
}
}
Future<void> logout() async {
await _withTimeout(_supabase.auth.signOut(), 'Logout');
userId = null;
userRole = null;
_profile = null;
cart.clear();
_discount = 0;
notifyListeners();
}
Future<String?> createKasir(String email, String password, String? name) async {
try {
final res = await _withTimeout(
_admin.auth.admin.createUser(
AdminUserAttributes(email: email, password: password, emailConfirm: true),
),
'Create kasir user',
);
final newUserId = res.user?.id;
if (newUserId != null) {
await _withTimeout(
_admin.from('profiles').upsert({
'id': newUserId,
'role': 'kasir',
'full_name': name,
}),
'Create kasir profile',
);
await loadKasirAccounts();
}
return null;
} on AuthException catch (e) {
return e.message;
} catch (e) {
return e.toString();
}
}
Future<void> deleteKasir(String id) async {
try {
await _withTimeout(
_admin.from('profiles').delete().eq('id', id),
'Delete kasir profile',
);
await _withTimeout(
_admin.auth.admin.deleteUser(id),
'Delete kasir user',
);
await loadKasirAccounts();
} catch (_) {}
}
Future<String?> updateKasir(String id, String email, String? name, String? password) async {
try {
final updateData = <String, dynamic>{
'full_name': name,
};
if (password != null && password.isNotEmpty) {
await _withTimeout(
_admin.auth.admin.updateUserById(
id,
attributes: AdminUserAttributes(password: password),
),
'Update kasir password',
);
}
await _withTimeout(
_admin.from('profiles').update(updateData).eq('id', id),
'Update kasir profile',
);
await loadKasirAccounts();
return null;
} on AuthException catch (e) {
return e.message;
} catch (e) {
return e.toString();
}
}
Future<void> loadKasirAccounts() async {
try {
final rows = await _withTimeout(
_admin
.from('profiles')
.select()
.eq('role', 'kasir'),
'Load kasir accounts',
);
Map<String, String> emails = {};
try {
final usersRes = await _withTimeout(
_admin.auth.admin.listUsers(),
'List auth users',
);
for (final u in usersRes) {
if (u.email != null && u.email!.isNotEmpty) {
emails[u.id] = u.email!;
}
}
} catch (_) {}
kasirAccounts = rows.map((r) {
final map = Map<String, dynamic>.from(r);
if ((map['email'] == null || map['email'].toString().isEmpty) &&
emails.containsKey(map['id'])) {
map['email'] = emails[map['id']];
}
if ((map['full_name'] == null || map['full_name'].toString().isEmpty) &&
map['email'] != null &&
map['email'].toString().isNotEmpty) {
map['full_name'] = map['email'].toString().split('@').first;
}
return map;
}).toList();
notifyListeners();
} catch (_) {}
}
Future<void> _loadProfileNames() async {
try {
final rows = await _withTimeout(
_admin.from('profiles').select('id,full_name,email'),
'Load profile names',
);
_profileNames.clear();
for (final r in rows) {
final map = Map<String, dynamic>.from(r);
final name = (map['full_name']?.toString().trim().isEmpty == true
? map['email']?.toString().split('@').first
: map['full_name']?.toString().trim()) ??
'';
if (name.isNotEmpty) {
_profileNames[map['id'] as String] = name;
}
}
} catch (_) {}
}
void _subscribe() {
_supabase.auth.onAuthStateChange.listen((event) {
if (event.session != null) {
userId = event.session!.user.id;
_loadProfile();
} else {
userId = null;
userRole = null;
_profile = null;
}
notifyListeners();
});
_supabase.from('products').stream(primaryKey: ['id']).listen(
(rows) {
products = rows.map((e) => Product.fromJson(e)).toList();
notifyListeners();
},
onError: (e) {
debugPrint('REALTIME products stream error: $e');
},
);
_supabase.from('sales').stream(primaryKey: ['id']).listen(
(rows) {
sales = rows.map((e) => Sale.fromJson(e)).toList();
sales.sort((a, b) => b.date.compareTo(a.date));
notifyListeners();
},
onError: (e) {
debugPrint('REALTIME sales stream error: $e');
},
);
}
Future<void> _loadProducts() async {
final rows = await _withTimeout(
_supabase.from('products').select().order('name'),
'Load products',
);
products = rows.map((e) => Product.fromJson(e)).toList();
if (products.isEmpty) _seed();
}
Future<void> _loadSales() async {
final rows = await _withTimeout(
_supabase
.from('sales')
.select()
.order('created_at', ascending: false),
'Load sales',
);
sales = rows.map((e) => Sale.fromJson(e)).toList();
sales.sort((a, b) => b.date.compareTo(a.date));
}
Future<void> _seed() async {
final seeds = [
Product(id: uid(), name: 'Beras 5kg', price: 58000, priceReseller: 55000, stock: 50),
Product(id: uid(), name: 'Minyak 2L', price: 32000, priceReseller: 30000, stock: 40),
Product(id: uid(), name: 'Gula 1kg', price: 14000, priceReseller: 13000, stock: 60),
Product(id: uid(), name: 'Teh Botol', price: 3600, priceReseller: 3200, stock: 120),
Product(id: uid(), name: 'Sabun Cuci', price: 8500, priceReseller: 7800, stock: 35),
Product(id: uid(), name: 'Indomie Dus', price: 95000, priceReseller: 90000, stock: 25),
];
await _withTimeout(
_supabase.from('products').insert(seeds.map((e) => e.toJson()).toList()),
'Seed products',
);
products = seeds;
notifyListeners();
}
/* ---------- Tipe Pembeli ---------- */
void setCustomerType(String type) {
customerType = type;
final reseller = type == 'reseller';
for (final c in cart) {
c.isReseller = reseller;
}
notifyListeners();
}
/* ---------- Produk ---------- */
Future<String?> addProduct(String name, String barcode, double price,
double priceReseller, int stock, int minStock, double hargaPokok) async {
try {
final trimmed = barcode.trim();
if (trimmed.isNotEmpty &&
products.any((p) => p.barcode.trim().toLowerCase() == trimmed.toLowerCase())) {
return 'Barcode sudah digunakan oleh produk lain';
}
final product = Product(
id: uid(),
name: name,
barcode: trimmed,
price: price,
priceReseller: priceReseller,
stock: stock,
minStock: minStock,
hargaPokok: hargaPokok,
);
await _withTimeout(
_supabase.from('products').insert(product.toJson()),
'Add product',
);
} catch (e) {
return e.toString();
}
try {
await _loadProducts();
} catch (_) {}
notifyListeners();
return null;
}
Future<String?> updateProduct(String id, String name, String barcode,
double price, double priceReseller, int stock, int minStock, double hargaPokok) async {
try {
final trimmed = barcode.trim();
if (trimmed.isNotEmpty &&
products.any((p) => p.id != id && p.barcode.trim().toLowerCase() == trimmed.toLowerCase())) {
return 'Barcode sudah digunakan oleh produk lain';
}
await _withTimeout(
_supabase.from('products').update({
'name': name,
'barcode': trimmed,
'price': price,
'price_reseller': priceReseller,
'stock': stock,
'min_stock': minStock,
'harga_pokok': hargaPokok,
}).eq('id', id),
'Update product',
);
} catch (e) {
return e.toString();
}
try {
await _loadProducts();
} catch (_) {}
notifyListeners();
return null;
}
Future<String?> deleteProduct(String id) async {
try {
cart.removeWhere((c) => c.product.id == id);
await _withTimeout(
_supabase.from('products').delete().eq('id', id),
'Delete product',
);
} catch (e) {
return e.toString();
}
try {
await _loadProducts();
} catch (_) {}
notifyListeners();
return null;
}
/* ---------- Cart ---------- */
void addToCart(Product product) {
if (product.stock <= 0) return;
final line = cart.firstWhere(
(c) => c.product.id == product.id,
orElse: () => CartLine(product: product),
);
if (cart.contains(line)) {
if (line.qty < product.stock) line.qty++;
} else {
line.isReseller = customerType == 'reseller';
cart.add(line);
}
notifyListeners();
}
void changeQty(String productId, int delta) {
final line = cart.firstWhere((c) => c.product.id == productId);
line.qty += delta;
if (line.qty <= 0) {
cart.remove(line);
} else if (line.qty > line.product.stock) {
line.qty = line.product.stock;
}
notifyListeners();
}
void toggleLineMode(String productId) {
final line = cart.firstWhere((c) => c.product.id == productId);
line.isReseller = !line.isReseller;
notifyListeners();
}
void setDiscount(double value) {
_discount = value < 0 ? 0 : value.clamp(0, subtotal);
notifyListeners();
}
void clearCart() {
cart.clear();
_discount = 0;
notifyListeners();
}
/* ---------- Checkout ---------- */
Future<Sale> checkout(double paid, {String paymentMethod = 'tunai', Map<String, double>? splitPayment, String paymentStatus = 'paid', double dueAmount = 0.0, String? customerPhone}) async {
final totalCost = cart.fold(0.0, (sum, c) => sum + (c.product.hargaPokok * c.qty));
final sale = Sale(
id: uid(),
date: DateTime.now(),
userId: userId,
items: cart
.map((c) => SaleItem(
productId: c.product.id,
name: c.product.name,
price: c.price,
qty: c.qty,
isReseller: c.isReseller,
))
.toList(),
subtotal: subtotal,
discount: _discount,
total: total,
paid: paid,
change: paid - total,
totalCost: totalCost,
paymentMethod: paymentMethod,
paymentStatus: paymentStatus,
dueAmount: dueAmount,
customerPhone: customerPhone,
splitPayment: splitPayment,
);
try {
await _withTimeout(
_supabase.from('sales').insert(sale.toJson()),
'Create sale',
);
for (final line in cart) {
try {
final current =
products.firstWhere((p) => p.id == line.product.id);
await _withTimeout(
_supabase
.from('products')
.update({'stock': current.stock - line.qty})
.eq('id', line.product.id),
'Update stock',
);
} catch (_) {}
}
await _loadProducts();
await _loadSales();
cart.clear();
_discount = 0;
notifyListeners();
return sale;
} catch (e) {
await _offlineQueue.addToQueue({
'sale': sale.toJson(),
'items': cart.map((c) {
final lineTotalCost = c.product.hargaPokok * c.qty;
return {
'product_id': c.product.id,
'qty': c.qty,
'price': c.price,
'total_cost': lineTotalCost,
};
}).toList(),
});
cart.clear();
_discount = 0;
notifyListeners();
rethrow;
}
}
/* ---------- Retur / Pembatalan ---------- */
Future<String?> cancelSale(String saleId) async {
try {
final sale = sales.firstWhere((s) => s.id == saleId);
await _withTimeout(
_supabase.from('sales').update({'is_cancelled': true}).eq('id', saleId),
'Cancel sale',
);
for (final item in sale.items) {
final product = products.firstWhere(
(p) => p.id == item.productId,
orElse: () => Product(id: '', name: '', price: 0, stock: 0, hargaPokok: 0),
);
if (product.id.isNotEmpty) {
await _withTimeout(
_supabase
.from('products')
.update({'stock': product.stock + item.qty})
.eq('id', product.id),
'Restore stock',
);
}
}
await _loadProducts();
await _loadSales();
notifyListeners();
return null;
} on PostgrestException catch (e) {
return e.message;
} catch (e) {
return e.toString();
}
}
Future<String?> recordPayment(String saleId, double amount) async {
try {
final sale = sales.firstWhere((s) => s.id == saleId);
final newPaid = sale.paid + amount;
final newDue = (sale.dueAmount - amount).clamp(0, double.infinity);
final newStatus = newDue <= 0 ? 'paid' : 'partial';
await _withTimeout(
_supabase.from('sales').update({
'paid': newPaid,
'due_amount': newDue,
'payment_status': newStatus,
}).eq('id', saleId),
'Record payment',
);
await _loadSales();
notifyListeners();
return null;
} on PostgrestException catch (e) {
return e.message;
} catch (e) {
return e.toString();
}
}
}

26
lib/utils/colors.dart Normal file
View File

@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
const kBackground = Color(0xFF0B1220);
const kSurface = Color(0xFF141C2E);
const kSurface2 = Color(0xFF1C2638);
const kBorder = Color(0xFF2A3650);
const kText = Color(0xFFE8EEF7);
const kMuted = Color(0xFF93A1B8);
const kAccent = Color(0xFFF59E0B);
const kSuccess = Color(0xFF22C55E);
const kDanger = Color(0xFFEF4444);
const kRadius = 18.0;
const List<Color> productPalette = [
Color(0xFFF59E0B),
Color(0xFF22C55E),
Color(0xFF3B82F6),
Color(0xFFEC4899),
];
Color productAccent(String name) {
if (name.isEmpty) return productPalette.first;
final index = name.hashCode.abs() % productPalette.length;
return productPalette[index];
}

26
lib/utils/format.dart Normal file
View File

@@ -0,0 +1,26 @@
String formatRupiah(double number) {
final rounded = number.round();
final formatted = rounded
.toString()
.replaceAllMapped(RegExp(r'\B(?=(\d{3})+(?!\d))'), (m) => '.');
return 'Rp $formatted';
}
String formatTanggal(DateTime date) {
const bulan = [
'Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun',
'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'
];
return '${date.day} ${bulan[date.month - 1]} ${date.year}';
}
String formatWaktu(DateTime date) {
final j = date.hour.toString().padLeft(2, '0');
final m = date.minute.toString().padLeft(2, '0');
return '$j:$m';
}
String uid() {
return DateTime.now().microsecondsSinceEpoch.toRadixString(36) +
(DateTime.now().millisecond).toRadixString(36);
}

View File

@@ -0,0 +1,60 @@
import 'package:flutter/services.dart';
class RupiahInputFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue, TextEditingValue newValue) {
if (newValue.text.isEmpty) {
return newValue;
}
final digits = newValue.text.replaceAll(RegExp(r'[^0-9]'), '');
if (digits.isEmpty) {
return const TextEditingValue(text: '');
}
final formatted = _formatRupiah(digits);
int newOffset = formatted.length;
if (oldValue.text.isNotEmpty) {
final oldLength = oldValue.text.replaceAll(RegExp(r'[^0-9]'), '').length;
final newLength = digits.length;
final oldCursor = oldValue.selection.baseOffset;
if (newLength < oldLength) {
final digitsBeforeCursor = oldValue.text.substring(0, oldCursor).replaceAll(RegExp(r'[^0-9]'), '').length;
newOffset = (digitsBeforeCursor).clamp(0, formatted.length);
} else if (oldCursor < oldValue.text.length) {
final digitsBeforeCursor = oldValue.text.substring(0, oldCursor).replaceAll(RegExp(r'[^0-9]'), '').length;
newOffset = (digitsBeforeCursor).clamp(0, formatted.length);
}
}
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: newOffset),
);
}
String _formatRupiah(String digits) {
final buffer = StringBuffer();
final len = digits.length;
for (int i = 0; i < len; i++) {
if (i > 0 && (len - i) % 3 == 0) {
buffer.write('.');
}
buffer.write(digits[i]);
}
return buffer.toString();
}
static String formatNumber(int number) {
return RupiahInputFormatter()._formatRupiah(number.toString());
}
static int? getRawValue(String formattedText) {
final digits = formattedText.replaceAll(RegExp(r'[^0-9]'), '');
if (digits.isEmpty) return null;
return int.tryParse(digits);
}
}