From a6f1c6a8b8b029677bfe4a59392aca919bf7a8a7 Mon Sep 17 00:00:00 2001 From: Bala Date: Thu, 22 Jan 2026 00:53:15 +0530 Subject: [PATCH] Odoo login issue fixed & POS list ui implemented. --- lib/screens/home_screen.dart | 14 +- lib/screens/login_screen.dart | 4 +- lib/screens/pos_list_screen.dart | 652 ++++++++++++++++++++++++++++ lib/screens/pos_selling_screen.dart | 586 +++++++++++++++++++++++++ lib/services/odoo_service.dart | 2 + lib/utils/constants.dart | 4 +- pubspec.lock | 16 +- pubspec.yaml | 2 +- 8 files changed, 1260 insertions(+), 20 deletions(-) create mode 100644 lib/screens/pos_list_screen.dart create mode 100644 lib/screens/pos_selling_screen.dart diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index fd84b4d..f6bb110 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -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 { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: InkWell( onTap: () { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Opening $name... (Not implemented in demo)')), - ); + 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( diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index 7d88c54..14e638e 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -15,8 +15,8 @@ class _LoginScreenState extends State { final _formKey = GlobalKey(); 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 { diff --git a/lib/screens/pos_list_screen.dart b/lib/screens/pos_list_screen.dart new file mode 100644 index 0000000..aecaac2 --- /dev/null +++ b/lib/screens/pos_list_screen.dart @@ -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 createState() => _PosListScreenState(); +} + +class _PosListScreenState extends State { + List _posConfigs = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _fetchPosConfigs()); + } + + Map _sessionDetails = {}; + + Future _fetchPosConfigs() async { + final odoo = Provider.of(context, listen: false); + try { + // 1. Fetch Configs + final List 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 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 details = {}; + if (sessionIds.isNotEmpty) { + final List 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 _openSession(int configId) async { + final odoo = Provider.of(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 _createPos(String name) async { + final odoo = Provider.of(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 _deletePos(int id, String name) async { + final odoo = Provider.of(context, listen: false); + + // Show confirmation dialog before deleting + final bool? confirm = await showDialog( + 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 _closeSession(int sessionId) async { + final odoo = Provider.of(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 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? 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)), + ], + ); + } +} diff --git a/lib/screens/pos_selling_screen.dart b/lib/screens/pos_selling_screen.dart new file mode 100644 index 0000000..1486de7 --- /dev/null +++ b/lib/screens/pos_selling_screen.dart @@ -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 createState() => _PosSellingScreenState(); +} + +class _PosSellingScreenState extends State { + // Mock Data + final List> _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> _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> _cartItems = []; + + void _addToCart(Map 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 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)), + ) + ], + ), + ); + } +} diff --git a/lib/services/odoo_service.dart b/lib/services/odoo_service.dart index 9929c8f..caa8cfd 100644 --- a/lib/services/odoo_service.dart +++ b/lib/services/odoo_service.dart @@ -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, diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 9d79941..c3d443e 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -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 } diff --git a/pubspec.lock b/pubspec.lock index 7396661..77b75da 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 1f68c75..ace841e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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