chore: initial commit Dutra Store POS app
This commit is contained in:
176
lib/main.dart
Normal file
176
lib/main.dart
Normal 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
51
lib/models/product.dart
Normal 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
118
lib/models/sale.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
25
lib/models/user_profile.dart
Normal file
25
lib/models/user_profile.dart
Normal 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
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
167
lib/services/export_service.dart
Normal file
167
lib/services/export_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
156
lib/services/offline_queue_service.dart
Normal file
156
lib/services/offline_queue_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
223
lib/services/printer_service.dart
Normal file
223
lib/services/printer_service.dart
Normal 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
760
lib/state/app_state.dart
Normal 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
26
lib/utils/colors.dart
Normal 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
26
lib/utils/format.dart
Normal 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);
|
||||
}
|
||||
60
lib/utils/rupiah_input.dart
Normal file
60
lib/utils/rupiah_input.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user