Odoo login issue fixed & POS list ui implemented.

This commit is contained in:
Bala 2026-01-22 00:53:15 +05:30
parent 8ad535e3f2
commit a6f1c6a8b8
8 changed files with 1260 additions and 20 deletions

View File

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/odoo_service.dart';
import '../utils/theme.dart';
import 'pos_list_screen.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@ -118,9 +119,16 @@ class _HomeScreenState extends State<HomeScreen> {
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: InkWell(
onTap: () {
if (name.toLowerCase().contains('point of sale')) {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const PosListScreen()),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Opening $name... (Not implemented in demo)')),
);
}
},
borderRadius: BorderRadius.circular(16),
child: Column(

View File

@ -15,8 +15,8 @@ class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _urlController = TextEditingController(text: AppConstants.defaultOdooUrl);
final _dbController = TextEditingController(text: AppConstants.dbName);
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _emailController = TextEditingController(text: 'alaguraj0361@gmail.com');
final _passwordController = TextEditingController(text: 'Alaguraj@123');
bool _isLoading = false;
void _login() async {

View File

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

View File

@ -0,0 +1,586 @@
import 'package:flutter/material.dart';
import '../utils/theme.dart';
class PosSellingScreen extends StatefulWidget {
final String shopName;
final int sessionId;
const PosSellingScreen({super.key, required this.shopName, required this.sessionId});
@override
State<PosSellingScreen> createState() => _PosSellingScreenState();
}
class _PosSellingScreenState extends State<PosSellingScreen> {
// Mock Data
final List<Map<String, dynamic>> _foodProducts = [
{'name': 'Bacon Burger', 'price': 12.0, 'image': 'https://plus.unsplash.com/premium_photo-1661331777080-459247d853e3?q=80&w=300&auto=format&fit=crop'},
{'name': 'Cheese Burger', 'price': 10.5, 'image': 'https://images.unsplash.com/photo-1568901346375-23c9450c58cd?q=80&w=300&auto=format&fit=crop'},
{'name': 'Pizza Margherita', 'price': 15.0, 'image': 'https://images.unsplash.com/photo-1513104890138-7c749659a591?q=80&w=300&auto=format&fit=crop'},
{'name': 'Pizza Veggie', 'price': 14.0, 'image': 'https://images.unsplash.com/photo-1604382354936-07c5d9983bd3?q=80&w=300&auto=format&fit=crop'},
{'name': 'Pasta Carbonara', 'price': 13.5, 'image': 'https://images.unsplash.com/photo-1612874742237-6526221588e3?q=80&w=300&auto=format&fit=crop'},
{'name': 'Club Sandwich', 'price': 9.0, 'image': 'https://images.unsplash.com/photo-1597255682855-667d4ccf044d?q=80&w=300&auto=format&fit=crop'},
];
final List<Map<String, dynamic>> _drinkProducts = [
{'name': 'Coca-Cola', 'price': 3.0, 'image': 'https://images.unsplash.com/photo-1622483767028-3f66f32aef97?q=80&w=300&auto=format&fit=crop'},
{'name': 'Water 500ml', 'price': 2.0, 'image': 'https://images.unsplash.com/photo-1616118132534-381148898bb6?q=80&w=300&auto=format&fit=crop'},
{'name': 'Espresso', 'price': 2.5, 'image': 'https://images.unsplash.com/photo-1514432324607-a09d9b4aefdd?q=80&w=300&auto=format&fit=crop'},
{'name': 'Green Tea', 'price': 3.5, 'image': 'https://images.unsplash.com/photo-1627435601361-ec25f5b1d0e5?q=80&w=300&auto=format&fit=crop'},
{'name': 'Milkshake', 'price': 5.0, 'image': 'https://images.unsplash.com/photo-1579954115563-e72bf1381629?q=80&w=300&auto=format&fit=crop'},
];
String _selectedCategory = 'Food'; // 'Food' or 'Drinks'
List<Map<String, dynamic>> _cartItems = [];
void _addToCart(Map<String, dynamic> product) {
setState(() {
final index = _cartItems.indexWhere((item) => item['name'] == product['name']);
if (index != -1) {
_cartItems[index]['qty'] += 1;
} else {
_cartItems.add({
'name': product['name'],
'price': product['price'],
'qty': 1,
});
}
});
}
void _removeFromCart(int index) {
setState(() {
_cartItems.removeAt(index);
});
}
void _decrementCartItem(int index) {
setState(() {
if (_cartItems[index]['qty'] > 1) {
_cartItems[index]['qty'] -= 1;
} else {
_cartItems.removeAt(index);
}
});
}
double get _totalAmount {
return _cartItems.fold(0.0, (sum, item) => sum + (item['price'] * item['qty']));
}
int get _totalItems {
return _cartItems.fold(0, (sum, item) => sum + (item['qty'] as int));
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF0F4F7),
body: LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth > 700) {
return _buildWideLayout();
} else {
return _buildMobileLayout();
}
},
),
);
}
Widget _buildWideLayout() {
return Row(
children: [
// Left: Register/Cart
Expanded(
flex: 4,
child: _buildCartPanel(isMobile: false),
),
// Right: Products
Expanded(
flex: 6,
child: _buildProductPanel(),
),
],
);
}
Widget _buildMobileLayout() {
return Stack(
children: [
// Product Panel Full Screen
Column(
children: [
Expanded(child: _buildProductPanel()),
const SizedBox(height: 80), // Space for bottom bar
],
),
// Cart Summary Bottom Bar
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _buildMobileBottomBar(),
),
],
);
}
Widget _buildMobileBottomBar() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF2C3E50),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.2), blurRadius: 10, offset: const Offset(0, -2))],
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
),
child: Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('$_totalItems Items', style: const TextStyle(color: Colors.white70, fontSize: 13)),
const SizedBox(height: 4),
Text('${_totalAmount.toStringAsFixed(2)}', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 20)),
],
),
const Spacer(),
ElevatedButton.icon(
onPressed: _showMobileCartModal,
icon: const Icon(Icons.shopping_cart_outlined),
label: const Text('View Cart'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.secondaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
],
),
);
}
void _showMobileCartModal() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => Container(
height: MediaQuery.of(context).size.height * 0.85,
decoration: const BoxDecoration(
color: Color(0xFF2C3E50),
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
children: [
// Handle
Container(width: 40, height: 4, margin: const EdgeInsets.symmetric(vertical: 12), decoration: BoxDecoration(color: Colors.white24, borderRadius: BorderRadius.circular(2))),
Expanded(child: _buildCartPanel(isMobile: true)),
],
),
),
);
}
Widget _buildCartPanel({required bool isMobile}) {
return Container(
color: const Color(0xFF2C3E50),
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 10),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: Colors.white10)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Current Order', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 18)),
if (isMobile)
IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.keyboard_arrow_down, color: Colors.white70))
else
IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.close, color: Colors.white70))
],
),
),
// Cart Items
Expanded(
child: _cartItems.isEmpty
? const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.shopping_cart_outlined, color: Colors.white24, size: 48),
SizedBox(height: 16),
Text('Cart is empty', style: TextStyle(color: Colors.white54)),
]
)
)
: ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: _cartItems.length,
itemBuilder: (context, index) {
final item = _cartItems[index];
return Container(
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(color: Colors.white.withOpacity(0.05), borderRadius: BorderRadius.circular(12)),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
title: Text(item['name'], style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600)),
subtitle: Text('${item['qty']} x ₹${item['price']}', style: const TextStyle(color: Colors.white70)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('${(item['price'] * item['qty']).toStringAsFixed(2)}', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.remove_circle_outline, size: 22, color: Colors.orangeAccent),
tooltip: 'Reduce Quantity',
onPressed: () => _decrementCartItem(index),
),
IconButton(
icon: const Icon(Icons.add_circle_outline, size: 22, color: Colors.greenAccent),
tooltip: 'Add Quantity',
onPressed: () => _addToCart(item),
),
],
),
),
);
},
),
),
// Totals & Actions
Container(
padding: const EdgeInsets.all(20),
decoration: const BoxDecoration(color: Color(0xFF233140)),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Total', style: TextStyle(color: Colors.white, fontSize: 18)),
Text('${_totalAmount.toStringAsFixed(2)}', style: const TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold)),
],
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFE91E63),
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text('Customer', style: TextStyle(color: Colors.white)),
)
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: _showPaymentModal,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF4CAF50),
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text('Payment', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
)
),
],
)
],
),
)
],
),
);
}
Widget _buildProductPanel() {
return Container(
color: const Color(0xFFF0F4F7),
child: Column(
children: [
// Top Bar
Container(
padding: const EdgeInsets.fromLTRB(16, 50, 16, 16), // Top padding for status bar if mobile
color: Colors.white,
child: Column(
children: [
Row(
children: [
// Back button here too for convenience? Or relying on Cart back
if (MediaQuery.of(context).size.width <= 700)
IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.arrow_back)),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildCategoryTab('Food', Icons.lunch_dining, const Color(0xFFF8BBD0), const Color(0xFFE91E63)),
const SizedBox(width: 12),
_buildCategoryTab('Drinks', Icons.local_drink, const Color(0xFFC8E6C9), const Color(0xFF4CAF50)),
],
),
),
),
],
),
],
),
),
// Grid
Expanded(
child: Padding(
padding: const EdgeInsets.all(16),
child: GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: MediaQuery.of(context).size.width > 900 ? 5 : (MediaQuery.of(context).size.width > 600 ? 4 : 2),
childAspectRatio: 0.8,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: _selectedCategory == 'Food' ? _foodProducts.length : _drinkProducts.length,
itemBuilder: (context, index) {
final product = _selectedCategory == 'Food' ? _foodProducts[index] : _drinkProducts[index];
return _buildProductCard(product);
},
),
),
),
],
),
);
}
Widget _buildCategoryTab(String title, IconData icon, Color bgColor, Color textColor) {
final bool isSelected = _selectedCategory == title;
return GestureDetector(
onTap: () => setState(() => _selectedCategory = title),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
decoration: BoxDecoration(
color: isSelected ? bgColor : Colors.grey[100],
borderRadius: BorderRadius.circular(12),
border: isSelected ? Border.all(color: textColor.withOpacity(0.5), width: 1.5) : null,
),
child: Row(
children: [
Icon(icon, color: isSelected ? textColor : Colors.grey[600], size: 20),
const SizedBox(width: 8),
Text(title, style: TextStyle(color: isSelected ? textColor : Colors.grey[800], fontWeight: FontWeight.w700)),
],
),
),
);
}
Widget _buildProductCard(Map<String, dynamic> product) {
return GestureDetector(
onTap: () => _addToCart(product),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 12, offset: const Offset(0, 4)),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
child: Image.network(
product['image'],
fit: BoxFit.cover,
errorBuilder: (ctx, err, stack) => Container(
color: Colors.grey[100],
child: const Icon(Icons.restaurant, color: Colors.grey, size: 30)
),
),
),
),
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(product['name'], style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Color(0xFF2D3748)), maxLines: 1, overflow: TextOverflow.ellipsis),
const SizedBox(height: 6),
Text('${product['price']}', style: const TextStyle(color: Color(0xFF38A169), fontWeight: FontWeight.bold, fontSize: 15)),
],
),
)
],
),
),
);
}
void _showPaymentModal() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => Container(
height: MediaQuery.of(context).size.height * 0.9,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: Colors.grey[200]!)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Payment', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.close)),
],
),
),
Expanded(
child: Row(
children: [
// Payment Methods (Left)
Expanded(
flex: 4,
child: Container(
color: const Color(0xFFF8F9FA),
child: ListView(
padding: const EdgeInsets.all(20),
children: [
_buildPaymentMethod('Cash', Icons.money, true),
const SizedBox(height: 10),
_buildPaymentMethod('Bank', Icons.credit_card, false),
const SizedBox(height: 10),
_buildPaymentMethod('Customer Account', Icons.person, false),
],
),
),
),
// Payment Details & Numpad (Right)
Expanded(
flex: 6,
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Total Display
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Remaining to Pay', style: TextStyle(color: Colors.grey[600], fontSize: 14, fontWeight: FontWeight.w500)),
const SizedBox(height: 8),
Text('${_totalAmount.toStringAsFixed(2)}', style: const TextStyle(fontSize: 42, fontWeight: FontWeight.bold, color: Color(0xFF2D3748))),
],
),
const Spacer(),
// Validate Button
SizedBox(
height: 56,
child: ElevatedButton.icon(
onPressed: () {
Navigator.pop(context); // Close Payment
_processOrderSuccess();
},
icon: const Icon(Icons.check_circle_outline),
label: const Text('Validate Order', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF4CAF50),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 4,
),
),
),
],
),
),
),
],
),
),
],
),
),
);
}
Widget _buildPaymentMethod(String name, IconData icon, bool selected) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: selected ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(12),
border: selected ? Border.all(color: AppTheme.primaryColor, width: 2) : Border.all(color: Colors.grey[300]!),
boxShadow: selected ? [BoxShadow(color: AppTheme.primaryColor.withOpacity(0.1), blurRadius: 10)] : [],
),
child: Row(
children: [
Icon(icon, color: selected ? AppTheme.primaryColor : Colors.grey[600]),
const SizedBox(width: 12),
Text(name, style: TextStyle(
fontWeight: FontWeight.bold,
color: selected ? AppTheme.primaryColor : Colors.grey[800],
fontSize: 16
)),
const Spacer(),
if (selected) const Icon(Icons.check_circle, color: AppTheme.primaryColor),
],
),
);
}
void _processOrderSuccess() {
setState(() {
_cartItems.clear();
});
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.check_circle, color: Colors.green, size: 64),
const SizedBox(height: 16),
const Text('Order Validated!', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text('Receipt has been printed.', style: TextStyle(color: Colors.grey)),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Next Order', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
)
],
),
);
}
}

View File

@ -68,6 +68,8 @@ class OdooService with ChangeNotifier {
}) async {
if (_client == null) throw Exception("Client not initialized");
// In odoo_rpc 0.4.x, callKw expects a Map of params
return _client!.callKw({
'model': model,

View File

@ -1,5 +1,5 @@
class AppConstants {
// Use 10.0.2.2 for Android emulator to access localhost
static const String defaultOdooUrl = 'http://192.168.1.2:10001';
static const String dbName = 'FOODIES-DELIGHT'; // Found in your local database
static const String defaultOdooUrl = 'https://food2.odoo.com';
static const String dbName = 'food2'; // Found in your local database
}

View File

@ -108,10 +108,10 @@ packages:
dependency: transitive
description:
name: http
sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2"
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "0.13.6"
version: "1.6.0"
http_parser:
dependency: transitive
description:
@ -188,10 +188,10 @@ packages:
dependency: "direct main"
description:
name: odoo_rpc
sha256: "735cad4a40dcba251f9a2441223d511d9f23fcc79c2c1e0faa773cb2996019cb"
sha256: c243639c4d33efe74c3227d9f8d10fa41599aee922795d909c6a9da497b18481
url: "https://pub.dev"
source: hosted
version: "0.4.5"
version: "0.7.1"
path:
dependency: transitive
description:
@ -224,14 +224,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
pedantic:
dependency: transitive
description:
name: pedantic
sha256: "67fc27ed9639506c856c840ccce7594d0bdcd91bc8d53d6e52359449a1d50602"
url: "https://pub.dev"
source: hosted
version: "1.11.1"
platform:
dependency: transitive
description:

View File

@ -34,7 +34,7 @@ dependencies:
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
odoo_rpc: ^0.4.1
odoo_rpc: ^0.7.1
provider: ^6.0.0
shared_preferences: ^2.2.0