odoo-mobile-app/lib/screens/pos_list_screen.dart

653 lines
24 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/odoo_service.dart';
import '../utils/theme.dart';
import 'pos_selling_screen.dart';
class PosListScreen extends StatefulWidget {
const PosListScreen({super.key});
@override
State<PosListScreen> createState() => _PosListScreenState();
}
class _PosListScreenState extends State<PosListScreen> {
List<dynamic> _posConfigs = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _fetchPosConfigs());
}
Map<int, dynamic> _sessionDetails = {};
Future<void> _fetchPosConfigs() async {
final odoo = Provider.of<OdooService>(context, listen: false);
try {
// 1. Fetch Configs
final List<dynamic> configs = await odoo.callKw(
model: 'pos.config',
method: 'search_read',
args: [],
kwargs: {
'fields': [
'id',
'name',
'current_session_id',
'current_session_state',
'pos_session_username'
],
},
);
// 2. Extract Session IDs
List<int> sessionIds = [];
for (var config in configs) {
if (config['current_session_id'] != null && config['current_session_id'] is List) {
sessionIds.add(config['current_session_id'][0]); // [id, name]
}
}
// 3. Fetch Session Details (if any valid sessions)
Map<int, dynamic> details = {};
if (sessionIds.isNotEmpty) {
final List<dynamic> sessions = await odoo.callKw(
model: 'pos.session',
method: 'read',
args: [sessionIds],
kwargs: {
'fields': ['start_at', 'cash_register_balance_start'] // Basic info. 'total_payments_amount' often requires computation, keeping it simple for now.
// Note: 'total_payments_amount' isn't always stored directly on session in some versions, or is 'cash_register_total_entry_encoding'.
// Let's rely on valid fields we can definitely verify.
// For now, we will show Date and Opening. "Sold" might require order calculation which is heavier.
},
);
for (var session in sessions) {
details[session['id']] = session;
}
}
if (mounted) {
setState(() {
_posConfigs = configs;
_sessionDetails = details;
_isLoading = false;
});
}
} catch (e, stackTrace) {
print('Error fetching POS configs: $e');
print(stackTrace);
if (mounted) {
setState(() { _isLoading = false; });
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error fetching POS data: $e')),
);
}
}
}
Future<void> _openSession(int configId) async {
final odoo = Provider.of<OdooService>(context, listen: false);
setState(() { _isLoading = true; });
try {
// Use 'create' on pos.session directly instead of open_session_cb
// This is more robust for API calls as open_session_cb returns a web action.
await odoo.callKw(
model: 'pos.session',
method: 'create',
args: [
{'config_id': configId}
],
kwargs: {},
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Session Opened Successfully!')),
);
_fetchPosConfigs();
}
} catch (e) {
if (mounted) {
setState(() { _isLoading = false; });
// Clean up error message for display
String errorMsg = e.toString();
if (errorMsg.contains('OdooException')) {
errorMsg = errorMsg.replaceAll('OdooException:', '').trim();
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error Opening Session: $errorMsg'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 4),
),
);
_fetchPosConfigs();
}
}
}
Future<void> _createPos(String name) async {
final odoo = Provider.of<OdooService>(context, listen: false);
setState(() { _isLoading = true; });
try {
await odoo.callKw(
model: 'pos.config',
method: 'create',
args: [
{'name': name}
],
kwargs: {},
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('POS Created Successfully')),
);
_fetchPosConfigs();
}
} catch (e) {
if (mounted) {
setState(() { _isLoading = false; });
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error creating POS: $e')),
);
}
}
}
Future<void> _deletePos(int id, String name) async {
final odoo = Provider.of<OdooService>(context, listen: false);
// Show confirmation dialog before deleting
final bool? confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(color: Colors.red[50], borderRadius: BorderRadius.circular(10)),
child: const Icon(Icons.warning_rounded, color: Colors.red),
),
const SizedBox(width: 12),
const Text('Delete Shop', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
content: Text(
'Are you sure you want to delete "$name"?\nThis action cannot be undone.',
style: TextStyle(color: Colors.grey[700], fontSize: 15),
),
actionsPadding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
style: TextButton.styleFrom(foregroundColor: Colors.grey[600]),
child: const Text('Cancel', style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold)),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
),
child: const Text('Delete')
),
],
),
);
if (confirm != true) return;
setState(() { _isLoading = true; });
try {
// Try to hard delete first
await odoo.callKw(
model: 'pos.config',
method: 'unlink',
args: [[id]],
kwargs: {},
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('POS Deleted Successfully')),
);
_fetchPosConfigs();
}
} catch (e) {
// If hard delete fails (likely due to existing data), try to archive
try {
await odoo.callKw(
model: 'pos.config',
method: 'write',
args: [[id], {'active': false, 'name': '$name (Archived)'}],
kwargs: {},
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('POS Archived Successfully (Hard delete restricted)')),
);
_fetchPosConfigs();
}
} catch (e2) {
if (mounted) {
setState(() { _isLoading = false; });
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Cannot Delete or Archive: $e2')),
);
_fetchPosConfigs();
}
}
}
}
Future<void> _closeSession(int sessionId) async {
final odoo = Provider.of<OdooService>(context, listen: false);
setState(() { _isLoading = true; });
try {
// 1. Try to validate the closing directly
await odoo.callKw(
model: 'pos.session',
method: 'action_pos_session_validate',
args: [[sessionId]],
kwargs: {},
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Session Closed Successfully')));
_fetchPosConfigs();
}
} catch (e) {
// Fallback or Error Display
if (mounted) {
setState(() { _isLoading = false; });
String errorMsg = e.toString();
if (errorMsg.contains('OdooException')) {
errorMsg = errorMsg.replaceAll('OdooException:', '').trim();
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Closing Failed: $errorMsg'), backgroundColor: Colors.red),
);
_fetchPosConfigs();
}
}
}
void _showCreateDialog() {
final TextEditingController nameController = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
title: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: Colors.green[50], borderRadius: BorderRadius.circular(12)),
child: const Icon(Icons.add_business_rounded, color: Colors.green, size: 28),
),
const SizedBox(width: 12),
const Expanded(child: Text('New Point of Sale', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18))),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Enter a unique name for your new shop to identify it easily.', style: TextStyle(color: Colors.grey[600], fontSize: 14)),
const SizedBox(height: 20),
TextField(
controller: nameController,
decoration: InputDecoration(
labelText: 'Shop Name',
hintText: 'e.g. Main Bar',
prefixIcon: const Icon(Icons.store, color: Colors.green),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.green.shade200)),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Colors.green, width: 2)),
filled: true,
fillColor: Colors.green.withOpacity(0.05),
),
autofocus: true,
cursorColor: Colors.green,
),
const SizedBox(height: 10),
],
),
actionsPadding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
actions: [
OutlinedButton(
onPressed: () => Navigator.pop(context),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.red),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text('Cancel', style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold)),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
if (nameController.text.isNotEmpty) {
_createPos(nameController.text);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
elevation: 4,
shadowColor: Colors.green.withOpacity(0.4),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text('Create Shop', style: TextStyle(fontWeight: FontWeight.bold)),
),
],
actionsAlignment: MainAxisAlignment.spaceEvenly,
),
);
}
// Update build to use extendBodyBehindAppBar: true
@override
Widget build(BuildContext context) {
return Scaffold(
extendBodyBehindAppBar: true,
appBar: AppBar(
title: const Text('Point of Sale', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 24)),
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
elevation: 0,
centerTitle: true,
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _showCreateDialog,
backgroundColor: AppTheme.primaryColor,
icon: const Icon(Icons.add, color: Colors.white),
label: const Text('New Shop', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
),
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF2bb1a5), Color(0xFF1a4d46)], // Rich Teal Gradient
),
),
child: _isLoading
? const Center(child: CircularProgressIndicator(color: Colors.white))
: SafeArea(
child: RefreshIndicator(
color: AppTheme.secondaryColor,
backgroundColor: Colors.white,
onRefresh: _fetchPosConfigs,
child: ListView.builder(
padding: const EdgeInsets.fromLTRB(20, 10, 20, 80),
itemCount: _posConfigs.length,
itemBuilder: (context, index) {
return _buildPosCard(_posConfigs[index]);
},
),
),
),
),
);
}
Widget _buildPosCard(Map<String, dynamic> config) {
final int id = config['id'];
final String name = config['name'] is String ? config['name'] : 'Shop';
final String status = config['current_session_state'] is String ? config['current_session_state'] : 'closed';
final dynamic rawUsername = config['pos_session_username'];
final String? username = rawUsername is String ? rawUsername : null;
// Get Session Details
Map<String, dynamic>? sessionData;
if (config['current_session_id'] != null && config['current_session_id'] is List) {
int sessionId = config['current_session_id'][0];
sessionData = _sessionDetails[sessionId];
}
// Format Data
String dateStr = '---';
String openingStr = '₹ 0.00';
// String soldStr = '₹ 0.00'; // Need complex calculation or field, omitting for simplicity/safety
if (sessionData != null) {
if (sessionData['start_at'] != null) {
// Simple date parsing or just display string
dateStr = sessionData['start_at'].toString().split(' ')[0]; // yyyy-mm-dd
}
if (sessionData['cash_register_balance_start'] != null) {
openingStr = '${sessionData['cash_register_balance_start']}';
}
}
Color statusColor;
String statusText;
IconData statusIcon;
Color cardAccentColor;
String mainButtonText;
VoidCallback mainButtonAction;
switch (status) {
case 'opened':
statusColor = const Color(0xFF4CAF50); // Bright Green
statusText = 'Active Session';
statusIcon = Icons.check_circle_rounded;
cardAccentColor = const Color(0xFFE8F5E9);
mainButtonText = 'Continue Selling';
mainButtonAction = () {
int sessionId = (config['current_session_id'] != null && config['current_session_id'] is List) ? config['current_session_id'][0] : 0;
Navigator.push(context, MaterialPageRoute(builder: (context) => PosSellingScreen(shopName: name, sessionId: sessionId)));
};
break;
case 'opening_control':
statusColor = const Color(0xFF2196F3); // Blue
statusText = 'Opening';
statusIcon = Icons.sync;
cardAccentColor = const Color(0xFFE3F2FD);
mainButtonText = 'Continue Selling';
mainButtonAction = () {
int sessionId = (config['current_session_id'] != null && config['current_session_id'] is List) ? config['current_session_id'][0] : 0;
Navigator.push(context, MaterialPageRoute(builder: (context) => PosSellingScreen(shopName: name, sessionId: sessionId)));
};
break;
case 'closing_control':
statusColor = const Color(0xFFFF9800); // Orange
statusText = 'Closing Control';
statusIcon = Icons.history_toggle_off;
cardAccentColor = const Color(0xFFFFF3E0);
mainButtonText = 'Close Session';
mainButtonAction = () {
int sessionId = (config['current_session_id'] != null && config['current_session_id'] is List) ? config['current_session_id'][0] : 0;
if (sessionId != 0) {
_closeSession(sessionId);
}
};
break;
case 'closed':
default:
statusColor = const Color(0xFF757575); // Grey
statusText = 'Closed';
statusIcon = Icons.storefront;
cardAccentColor = const Color(0xFFF5F5F5);
mainButtonText = 'Open Register';
mainButtonAction = () => _openSession(id);
break;
}
return Container(
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 15,
offset: const Offset(0, 8),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Stack(
children: [
// Decorative background circle
Positioned(
right: -20,
top: -20,
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: cardAccentColor,
shape: BoxShape.circle,
),
),
),
Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Row
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: cardAccentColor,
borderRadius: BorderRadius.circular(16),
),
child: Icon(Icons.store_mall_directory_rounded, color: statusColor, size: 28),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF2D3748),
letterSpacing: 0.5,
),
),
const SizedBox(height: 4),
Row(
children: [
Icon(statusIcon, size: 14, color: statusColor),
const SizedBox(width: 6),
Text(
statusText,
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.w700,
fontSize: 13,
),
),
],
),
],
),
),
IconButton(
onPressed: () => _deletePos(id, name),
icon: Icon(Icons.delete_outline_rounded, color: Colors.grey[400]),
tooltip: 'Delete',
),
],
),
// Info Stats Row (Only if not closed or has session info)
if (status != 'closed' || sessionData != null) ...[
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[50],
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey[200]!),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildStatItem('Date', dateStr),
_buildStatItem('Opening', openingStr),
// _buildStatItem('Sold', soldStr), // Placeholder for future
],
),
),
],
if (username != null && status != 'closed') ...[
const SizedBox(height: 16),
Row(
children: [
CircleAvatar(
radius: 10,
backgroundColor: Colors.grey[300],
child: const Icon(Icons.person, size: 14, color: Colors.white),
),
const SizedBox(width: 8),
Text(
'Session by $username',
style: TextStyle(color: Colors.grey[600], fontWeight: FontWeight.w500, fontSize: 13),
),
],
),
],
const SizedBox(height: 24),
// Action Button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: mainButtonAction,
style: ElevatedButton.styleFrom(
backgroundColor: status != 'closed' ? statusColor : AppTheme.secondaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
elevation: 4,
shadowColor: statusColor.withOpacity(0.4),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
child: Text(
mainButtonText,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
),
),
],
),
),
],
),
),
);
}
Widget _buildStatItem(String label, String value) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(color: Colors.grey[500], fontSize: 12, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text(value, style: const TextStyle(color: Color(0xFF2D3748), fontSize: 14, fontWeight: FontWeight.bold)),
],
);
}
}